Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a48912cbb1 | |||
| 2ea4bb793e | |||
| d07323ef59 | |||
| 5ce89dc2ad | |||
| 589ef16128 | |||
| 386afb77e0 | |||
| 29af2e39ff | |||
| 090a26a301 | |||
| ce9a875181 | |||
| db88c42335 | |||
| adba484df4 | |||
| 37c263e153 | |||
| 204f48de8b |
@@ -41,7 +41,12 @@ const assistants = Array.from({ length: 14 }, (_, index) => {
|
||||
const messages = [user, ...assistants]
|
||||
const target = fixture.sessions.find((session) => session.id === fixture.targetID)!
|
||||
const lastID = userID
|
||||
const lastPartID = assistants.at(-1)!.parts.at(-1)!.id
|
||||
const lastAssistant = assistants.at(-1)!
|
||||
const lastPart = lastAssistant.parts.at(-1)!
|
||||
const lastPartID =
|
||||
lastPart.type === "tool"
|
||||
? lastPart.id
|
||||
: `${lastAssistant.info.id}:${lastPart.type}:${lastAssistant.parts.filter((part) => part.type === lastPart.type).length - 1}`
|
||||
|
||||
benchmark("hydrates an orphaned latest turn after a cold session click", async ({ browser, report }, testInfo) => {
|
||||
benchmark.setTimeout(180_000)
|
||||
@@ -107,9 +112,25 @@ async function trial(page: Page, mode: ParentHydrationBenchmarkMode) {
|
||||
return { items: items.slice(start, end), cursor: start > 0 ? items[start]!.info.id : undefined }
|
||||
},
|
||||
})
|
||||
await page.route(`**/session/${fixture.targetID}`, (route) =>
|
||||
route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(target) }),
|
||||
)
|
||||
await page.route(`**/session/${fixture.targetID}`, (route) => {
|
||||
const current = new URL(route.request().url()).pathname.startsWith("/api/")
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(
|
||||
current
|
||||
? {
|
||||
data: {
|
||||
...target,
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
location: { directory: target.directory },
|
||||
},
|
||||
}
|
||||
: target,
|
||||
),
|
||||
})
|
||||
})
|
||||
await installStressSessionTabs(page, { sessionIDs: [fixture.sourceID] })
|
||||
await page.goto(stressSessionHref(fixture.sourceID))
|
||||
await expectSessionTitle(page, fixture.expected.sourceTitle)
|
||||
@@ -144,8 +165,8 @@ async function trial(page: Page, mode: ParentHydrationBenchmarkMode) {
|
||||
parent: requests.filter((request) => request.type === "parent").length,
|
||||
}
|
||||
if (mode === "candidate") {
|
||||
expect(requestCounts.parent).toBe(1)
|
||||
expect(historyGates).toBe(1)
|
||||
expect(requestCounts.parent).toBe(0)
|
||||
expect(historyGates).toBe(0)
|
||||
}
|
||||
return { metrics, requestCounts, historyGateCount: historyGates }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { expect, test, type Page, type Route } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { currentSession } from "../utils/mock-server"
|
||||
|
||||
const serverA = "http://127.0.0.1:4096"
|
||||
const serverB = "http://127.0.0.1:4097"
|
||||
@@ -33,7 +34,7 @@ test("closing the active server's last tab opens the remaining server tab", asyn
|
||||
await tabA.locator('[data-slot="tab-close"] button').click()
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
|
||||
await expect.poll(() => requests.some((url) => url.startsWith(`${serverB}/session/${sessionB.id}`))).toBe(true)
|
||||
await expect.poll(() => requests.some((url) => url.startsWith(`${serverB}/api/session/${sessionB.id}`))).toBe(true)
|
||||
await expect(page.getByText(sessionB.title).first()).toBeVisible()
|
||||
const sessionBRequests = requests.filter((url) => url.includes(`/session/${sessionB.id}`))
|
||||
expect(sessionBRequests.every((url) => url.startsWith(serverB))).toBe(true)
|
||||
@@ -84,17 +85,21 @@ async function mockServers(page: Page, requests: string[]) {
|
||||
const current = url.origin === serverA ? sessionA : sessionB
|
||||
const directory = url.searchParams.get("directory")
|
||||
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
|
||||
if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route)
|
||||
if (url.pathname === "/global/health") return json(route, { healthy: true })
|
||||
if (url.pathname === "/session") return json(route, [current])
|
||||
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
|
||||
return sse(route)
|
||||
if (url.pathname === "/global/health") return json(route, {}, 404)
|
||||
if (url.pathname === "/api/health") return json(route, { pid: 1 })
|
||||
if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
|
||||
if (url.pathname === "/api/session/active") return json(route, { data: {} })
|
||||
if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) })
|
||||
if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} })
|
||||
if (url.pathname === `/session/${current.id}`) return json(route, current)
|
||||
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
||||
if (url.pathname === `/session/${current.id}/message`) return json(route, [])
|
||||
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
|
||||
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
|
||||
return json(route, [])
|
||||
if (["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"].includes(url.pathname))
|
||||
return json(route, {})
|
||||
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
|
||||
if (url.pathname === "/provider")
|
||||
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
|
||||
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
|
||||
@@ -116,7 +121,20 @@ async function mockServers(page: Page, requests: string[]) {
|
||||
directory: current.directory,
|
||||
home: current.directory,
|
||||
})
|
||||
if (url.pathname === "/api/path")
|
||||
return json(route, {
|
||||
state: current.directory,
|
||||
config: current.directory,
|
||||
worktree: current.directory,
|
||||
directory: current.directory,
|
||||
home: current.directory,
|
||||
})
|
||||
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
|
||||
if (url.pathname === "/api/vcs")
|
||||
return json(route, {
|
||||
location: { directory: current.directory },
|
||||
data: { branch: "main", defaultBranch: "main" },
|
||||
})
|
||||
return json(route, {})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { expect, test, type Page, type Route } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { currentSession } from "../utils/mock-server"
|
||||
|
||||
const serverA = "http://127.0.0.1:4096"
|
||||
const serverB = "http://127.0.0.1:4097"
|
||||
@@ -57,11 +58,15 @@ async function mockServers(page: Page) {
|
||||
const current = url.origin === serverA ? sessionA : sessionB
|
||||
const directory = url.searchParams.get("directory")
|
||||
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
|
||||
if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route)
|
||||
if (url.pathname === "/global/health") return json(route, { healthy: true })
|
||||
if (url.pathname === "/session/status")
|
||||
return json(route, url.origin === serverB ? { [sessionB.id]: { type: "busy" } } : {})
|
||||
if (url.pathname === "/session") return json(route, [current])
|
||||
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
|
||||
return sse(route, url.pathname === "/api/event")
|
||||
if (url.pathname === "/global/health") return json(route, {}, 404)
|
||||
if (url.pathname === "/api/health") return json(route, { pid: 1 })
|
||||
if (url.pathname === "/api/session/active")
|
||||
return json(route, { data: url.origin === serverB ? { [sessionB.id]: { type: "running" } } : {} })
|
||||
if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
|
||||
if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) })
|
||||
if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} })
|
||||
if (url.pathname === `/session/${current.id}`) return json(route, current)
|
||||
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
||||
if (url.pathname === `/session/${current.id}/message`) return json(route, [])
|
||||
@@ -90,7 +95,20 @@ async function mockServers(page: Page) {
|
||||
directory: current.directory,
|
||||
home: current.directory,
|
||||
})
|
||||
if (url.pathname === "/api/path")
|
||||
return json(route, {
|
||||
state: current.directory,
|
||||
config: current.directory,
|
||||
worktree: current.directory,
|
||||
directory: current.directory,
|
||||
home: current.directory,
|
||||
})
|
||||
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
|
||||
if (url.pathname === "/api/vcs")
|
||||
return json(route, {
|
||||
location: { directory: current.directory },
|
||||
data: { branch: "main", defaultBranch: "main" },
|
||||
})
|
||||
return json(route, {})
|
||||
})
|
||||
}
|
||||
@@ -104,6 +122,10 @@ function json(route: Route, body: unknown, status = 200) {
|
||||
})
|
||||
}
|
||||
|
||||
function sse(route: Route) {
|
||||
return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
|
||||
function sse(route: Route, current: boolean) {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "text/event-stream",
|
||||
body: current ? 'data: {"id":"evt_connected","type":"server.connected","data":{}}\n\n' : ": ok\n\n",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -133,7 +133,7 @@ test("opens and searches project files inline", async ({ page }) => {
|
||||
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1)
|
||||
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
|
||||
await expect(sidebarToggle).toBeDisabled()
|
||||
await panel.getByRole("tab", { name: /Review/ }).click()
|
||||
await panel.locator("#session-side-panel-review-tab").click()
|
||||
await expect(sidebarToggle).toBeEnabled()
|
||||
await panel.getByRole("tab", { name: "Open file" }).click()
|
||||
await page.keyboard.press("Control+w")
|
||||
|
||||
@@ -46,7 +46,7 @@ test("restores review mode and selected file per session", async ({ page }) => {
|
||||
|
||||
async function selectMode(page: Page, current: string, next: string) {
|
||||
await page.getByRole("button", { name: current }).click()
|
||||
await page.getByRole("option", { name: next }).click()
|
||||
await page.getByRole("option", { name: next }).dispatchEvent("click")
|
||||
}
|
||||
|
||||
async function selectFile(page: Page, file: string) {
|
||||
|
||||
@@ -16,8 +16,8 @@ test("shows loaded sessions before the directory path request resolves", async (
|
||||
const pathBlocked = new Promise<void>((resolve) => {
|
||||
releasePath = resolve
|
||||
})
|
||||
await page.route("**/path?*", async (route) => {
|
||||
if (!new URL(route.request().url()).searchParams.has("directory")) return route.fallback()
|
||||
await page.route("**/api/path?*", async (route) => {
|
||||
if (!new URL(route.request().url()).searchParams.has("location[directory]")) return route.fallback()
|
||||
await pathBlocked
|
||||
return route.fallback()
|
||||
})
|
||||
|
||||
@@ -42,7 +42,8 @@ test("shows a pending question dock", async ({ page }) => {
|
||||
const rejectRequests: string[] = []
|
||||
page.on("request", (request) => {
|
||||
if (request.method() !== "POST") return
|
||||
if (new URL(request.url()).pathname === "/question/question-request/reject") rejectRequests.push(request.url())
|
||||
if (new URL(request.url()).pathname === `/api/session/${sessionID}/question/question-request/reject`)
|
||||
rejectRequests.push(request.url())
|
||||
})
|
||||
|
||||
await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click()
|
||||
@@ -64,7 +65,9 @@ test("shows a pending question dock", async ({ page }) => {
|
||||
|
||||
await question.getByRole("radio", { name: /Minimal/ }).click()
|
||||
const reply = page.waitForRequest(
|
||||
(request) => request.method() === "POST" && new URL(request.url()).pathname === "/question/question-request/reply",
|
||||
(request) =>
|
||||
request.method() === "POST" &&
|
||||
new URL(request.url()).pathname === `/api/session/${sessionID}/question/question-request/reply`,
|
||||
)
|
||||
await question.getByRole("button", { name: "Submit" }).click()
|
||||
expect((await reply).postDataJSON()).toEqual({ answers: [["Minimal"]] })
|
||||
@@ -97,8 +100,8 @@ test("shows a pending permission dock", async ({ page }) => {
|
||||
const reply = page.waitForRequest((request) => request.method() === "POST")
|
||||
await permission.getByRole("button", { name: "Allow once" }).click()
|
||||
const request = await reply
|
||||
expect(new URL(request.url()).pathname).toBe(`/session/${sessionID}/permissions/permission-request`)
|
||||
expect(request.postDataJSON()).toEqual({ response: "once" })
|
||||
expect(new URL(request.url()).pathname).toBe(`/api/session/${sessionID}/permission/permission-request/reply`)
|
||||
expect(request.postDataJSON()).toEqual({ reply: "once" })
|
||||
})
|
||||
|
||||
test("restores the draft caret before typing after a request dock closes", async ({ page }) => {
|
||||
@@ -170,6 +173,7 @@ async function mockServer(
|
||||
},
|
||||
) {
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: "v2",
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { currentSession, mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const directory = "C:/OpenCode/SubagentNavigation"
|
||||
@@ -72,16 +72,19 @@ async function setup(page: Page, events?: () => EventPayload[]) {
|
||||
events,
|
||||
eventRetry: events ? 16 : undefined,
|
||||
})
|
||||
// The child session resolves via /session/:id but is absent from the /session list,
|
||||
// The child session resolves by ID but is absent from the session list,
|
||||
// matching a subagent session that has not been loaded into the list cache yet.
|
||||
await page.route(
|
||||
(url) => url.pathname === "/session" && url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"),
|
||||
(url) => url.pathname === "/api/session" && url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"),
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
body: JSON.stringify([session(parentID, parentTitle, 1700000000000)]),
|
||||
body: JSON.stringify({
|
||||
data: [currentSession(session(parentID, parentTitle, 1700000000000))],
|
||||
cursor: {},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
await configurePage(page)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { expect, test, type Page, type Route } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { currentSession } from "../utils/mock-server"
|
||||
|
||||
const server = "http://127.0.0.1:4096"
|
||||
const sessionA = session("ses_tab_a", "Tab A session")
|
||||
@@ -56,9 +57,15 @@ async function mockServer(page: Page) {
|
||||
await page.route("**/*", async (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
if (url.origin !== server) return route.fallback()
|
||||
if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route)
|
||||
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
|
||||
return sse(route)
|
||||
if (url.pathname === "/global/health") return json(route, { healthy: true })
|
||||
if (url.pathname === "/session") return json(route, sessions)
|
||||
if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} })
|
||||
if (url.pathname === "/api/session/active") return json(route, { data: {} })
|
||||
const currentSessionInfo = sessions.find((item) => url.pathname === `/api/session/${item.id}`)
|
||||
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
|
||||
if (sessions.some((item) => url.pathname === `/api/session/${item.id}/message`))
|
||||
return json(route, { data: [], cursor: {} })
|
||||
const byId = sessions.find((item) => url.pathname === `/session/${item.id}`)
|
||||
if (byId) return json(route, byId)
|
||||
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
||||
@@ -66,8 +73,7 @@ async function mockServer(page: Page) {
|
||||
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
|
||||
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
|
||||
return json(route, [])
|
||||
if (["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"].includes(url.pathname))
|
||||
return json(route, {})
|
||||
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
|
||||
if (url.pathname === "/provider")
|
||||
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
|
||||
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
|
||||
@@ -89,7 +95,20 @@ async function mockServer(page: Page) {
|
||||
directory: sessionA.directory,
|
||||
home: sessionA.directory,
|
||||
})
|
||||
if (url.pathname === "/api/path")
|
||||
return json(route, {
|
||||
state: sessionA.directory,
|
||||
config: sessionA.directory,
|
||||
worktree: sessionA.directory,
|
||||
directory: sessionA.directory,
|
||||
home: sessionA.directory,
|
||||
})
|
||||
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
|
||||
if (url.pathname === "/api/vcs")
|
||||
return json(route, {
|
||||
location: { directory: sessionA.directory },
|
||||
data: { branch: "main", defaultBranch: "main" },
|
||||
})
|
||||
return json(route, {})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -61,7 +61,12 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
route,
|
||||
path === "/api/event"
|
||||
? [{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events?.map(currentEvent) ?? [])]
|
||||
: events,
|
||||
: [
|
||||
...(path === "/global/event"
|
||||
? [{ payload: { id: "evt_mock_connected", type: "server.connected", properties: {} } }]
|
||||
: []),
|
||||
...(events ?? []),
|
||||
],
|
||||
config.eventRetry,
|
||||
)
|
||||
}
|
||||
@@ -200,6 +205,12 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") {
|
||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
}
|
||||
if (/^\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") {
|
||||
return json(route, true)
|
||||
}
|
||||
if (/^\/session\/[^/]+\/permissions\/[^/]+$/.test(path) && route.request().method() === "POST") {
|
||||
return json(route, true)
|
||||
}
|
||||
if (
|
||||
/^\/api\/session\/[^/]+\/(archive|rename|interrupt|revert\/clear|revert\/commit)$/.test(path) &&
|
||||
route.request().method() === "POST"
|
||||
|
||||
@@ -197,6 +197,14 @@ export async function installSseTransport<T>(
|
||||
controller.enqueue(
|
||||
encoder.encode(frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} })),
|
||||
)
|
||||
if (url.pathname === "/global/event")
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
frame({
|
||||
payload: { id: `evt_mock_connected_${id}`, type: "server.connected", properties: {} },
|
||||
}),
|
||||
),
|
||||
)
|
||||
request.signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
|
||||
@@ -67,7 +67,8 @@ import { legacySessionHref, legacySessionServer, requireServerKey, sessionHref }
|
||||
import { createSessionLineage } from "@/pages/session/session-lineage"
|
||||
|
||||
import { SessionPage, SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session"
|
||||
import { NewHome, LegacyHome } from "@/pages/home"
|
||||
import { NewHome } from "@/pages/home"
|
||||
import { LegacyHome } from "@/pages/home/legacy-home"
|
||||
|
||||
const NewSession = lazy(() => import("@/pages/new-session"))
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import type { GlobalSession, Project } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Project } from "@opencode-ai/sdk/v2/client"
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { createMemo, onCleanup } from "solid-js"
|
||||
import { commandPaletteOptions, useCommand, type CommandOption } from "@/context/command"
|
||||
@@ -13,6 +14,7 @@ import { useTabs } from "@/context/tabs"
|
||||
import { displayName, projectForSession } from "@/pages/layout/helpers"
|
||||
import { createSessionTabs } from "@/pages/session/helpers"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { normalizeSessionInfo } from "@/utils/session"
|
||||
|
||||
export type CommandPaletteEntry = {
|
||||
id: string
|
||||
@@ -144,8 +146,7 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
|
||||
server: ServerConnection.key(serverSDK.server),
|
||||
opened: serverCtx.projects.list,
|
||||
stored: () => serverCtx.sync.data.project,
|
||||
load: (search, signal) =>
|
||||
serverSDK.client.experimental.session.list({ roots: true, search, limit: 50 }, { signal }),
|
||||
load: (search, signal) => serverSDK.api.session.list({ parentID: null, search, limit: 50 }, { signal }),
|
||||
untitled: () => language.t("command.session.new"),
|
||||
category: () => language.t("command.category.session"),
|
||||
})
|
||||
@@ -219,7 +220,7 @@ export function createServerSessionEntries(props: {
|
||||
server: ServerConnection.Key
|
||||
opened: () => LocalProject[]
|
||||
stored: () => Project[]
|
||||
load: (search: string, signal: AbortSignal) => Promise<{ data?: GlobalSession[] }>
|
||||
load: (search: string, signal: AbortSignal) => Promise<{ data: SessionInfo[] }>
|
||||
untitled: () => string
|
||||
category: () => string
|
||||
}) {
|
||||
@@ -255,7 +256,8 @@ export function createServerSessionEntries(props: {
|
||||
return props
|
||||
.load(search, current.signal)
|
||||
.then((result) =>
|
||||
(result.data ?? [])
|
||||
result.data
|
||||
.map(normalizeSessionInfo)
|
||||
.filter((session) => !session.time.archived)
|
||||
.map((session) => {
|
||||
const project =
|
||||
@@ -264,7 +266,7 @@ export function createServerSessionEntries(props: {
|
||||
id: `session:${props.server}:${session.id}`,
|
||||
type: "session" as const,
|
||||
title: session.title || props.untitled(),
|
||||
description: project ? displayName(project) : session.project?.name || getFilename(session.directory),
|
||||
description: project ? displayName(project) : getFilename(session.directory),
|
||||
category: props.category(),
|
||||
directory: session.directory,
|
||||
sessionID: session.id,
|
||||
|
||||
@@ -79,8 +79,7 @@ export function DialogHomeCommandPaletteV2(props: {
|
||||
server: ServerConnection.key(props.server),
|
||||
opened: serverCtx.projects.list,
|
||||
stored: () => serverCtx.sync.data.project,
|
||||
load: (search, signal) =>
|
||||
serverCtx.sdk.client.experimental.session.list({ roots: true, search, limit: 50 }, { signal }),
|
||||
load: (search, signal) => serverCtx.sdk.api.session.list({ parentID: null, search, limit: 50 }, { signal }),
|
||||
untitled: () => language.t("command.session.new"),
|
||||
category: () => language.t("command.category.session"),
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2/client"
|
||||
import type { IntegrationMethod, IntegrationOauthConnectOutput } from "@opencode-ai/client/promise"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
@@ -28,6 +28,8 @@ import {
|
||||
Switch,
|
||||
} from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { useQueryClient } from "@tanstack/solid-query"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { Link } from "@/components/link"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
@@ -35,8 +37,11 @@ import { useLanguage } from "@/context/language"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||
import { CustomProviderForm } from "./dialog-custom-provider"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
|
||||
const CUSTOM_ID = "_custom"
|
||||
type ConnectMethod = Extract<IntegrationMethod, { type: "key" | "oauth" }>
|
||||
|
||||
export function useProviderConnectController(options: { onBack?: () => void } = {}) {
|
||||
const [store, setStore] = createStore({ selected: undefined as string | undefined })
|
||||
@@ -228,8 +233,6 @@ function ProviderPickerV2(props: {
|
||||
}) {
|
||||
const providers = useProviders(props.directory)
|
||||
const language = useLanguage()
|
||||
const serverSync = useServerSync()
|
||||
const serverSDK = useServerSDK()
|
||||
const [store, setStore] = createStore({
|
||||
filter: "",
|
||||
active: undefined as string | undefined,
|
||||
@@ -266,19 +269,7 @@ function ProviderPickerV2(props: {
|
||||
|
||||
const connect = (provider: string) => {
|
||||
props.onPrepare?.()
|
||||
if (provider === CUSTOM_ID || serverSync().data.provider_auth[provider]) {
|
||||
props.onSelect(provider)
|
||||
return
|
||||
}
|
||||
if (store.connecting) return
|
||||
setStore("connecting", provider)
|
||||
void serverSDK()
|
||||
.client.provider.auth()
|
||||
.then((response) => {
|
||||
serverSync().set("provider_auth", response.data ?? {})
|
||||
props.onSelect(provider)
|
||||
})
|
||||
.catch(() => props.onSelect(provider))
|
||||
props.onSelect(provider)
|
||||
}
|
||||
|
||||
const move = (event: KeyboardEvent, direction: number) => {
|
||||
@@ -395,10 +386,17 @@ function ProviderConnection(props: {
|
||||
const dialog = useDialog()
|
||||
const serverSync = useServerSync()
|
||||
const serverSDK = useServerSDK()
|
||||
const queryClient = useQueryClient()
|
||||
const params = useParams()
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
const newLayout = settings.general.newLayoutDesigns
|
||||
const providers = useProviders(props.directory)
|
||||
const directory = () => props.directory?.() ?? decode64(params.dir)
|
||||
const location = () => {
|
||||
const value = directory()
|
||||
return value ? { directory: value } : undefined
|
||||
}
|
||||
|
||||
const alive = { value: true }
|
||||
const timer = { current: undefined as ReturnType<typeof setTimeout> | undefined }
|
||||
@@ -413,38 +411,34 @@ function ProviderConnection(props: {
|
||||
const provider = createMemo(
|
||||
() => providers.all().get(props.provider) ?? serverSync().data.provider.all.get(props.provider)!,
|
||||
)
|
||||
const fallback = createMemo<ProviderAuthMethod[]>(() => [
|
||||
const fallback = createMemo<ConnectMethod[]>(() => [
|
||||
{
|
||||
type: "api" as const,
|
||||
type: "key" as const,
|
||||
label: language.t("provider.connect.method.apiKey"),
|
||||
},
|
||||
])
|
||||
const [auth] = createResource(
|
||||
() => props.provider,
|
||||
async () => {
|
||||
const cached = serverSync().data.provider_auth[props.provider]
|
||||
if (cached) return cached
|
||||
const res = await serverSDK().client.provider.auth()
|
||||
if (!alive.value) return fallback()
|
||||
serverSync().set("provider_auth", res.data ?? {})
|
||||
return res.data?.[props.provider] ?? fallback()
|
||||
},
|
||||
const [integration] = createResource(
|
||||
() => ({ provider: props.provider, directory: directory() }),
|
||||
(input) =>
|
||||
serverSDK()
|
||||
.api.integration.get({
|
||||
integrationID: input.provider,
|
||||
location: input.directory ? { directory: input.directory } : undefined,
|
||||
})
|
||||
.then((result) => result.data),
|
||||
)
|
||||
const loading = createMemo(() => auth.loading && !serverSync().data.provider_auth[props.provider])
|
||||
const methods = createMemo(() => auth.latest ?? serverSync().data.provider_auth[props.provider] ?? fallback())
|
||||
const cachedMethods = serverSync().data.provider_auth[props.provider]
|
||||
const directMethod =
|
||||
cachedMethods?.length === 1 && cachedMethods[0].type === "api" && !cachedMethods[0].prompts?.length ? 0 : undefined
|
||||
const loading = createMemo(() => integration.loading)
|
||||
const methods = createMemo<ConnectMethod[]>(() => {
|
||||
const values = integration.latest?.methods.filter(
|
||||
(method): method is ConnectMethod => method.type === "key" || method.type === "oauth",
|
||||
)
|
||||
return values?.length ? values : fallback()
|
||||
})
|
||||
const [store, setStore] = createStore({
|
||||
methodIndex: directMethod as undefined | number,
|
||||
authorization: undefined as undefined | ProviderAuthAuthorization,
|
||||
methodIndex: undefined as undefined | number,
|
||||
authorization: undefined as undefined | IntegrationOauthConnectOutput["data"],
|
||||
promptInputs: undefined as undefined | Record<string, string>,
|
||||
state: (directMethod === undefined ? "pending" : undefined) as
|
||||
| undefined
|
||||
| "pending"
|
||||
| "complete"
|
||||
| "error"
|
||||
| "prompt",
|
||||
state: "pending" as undefined | "pending" | "complete" | "error" | "prompt",
|
||||
error: undefined as string | undefined,
|
||||
})
|
||||
|
||||
@@ -454,7 +448,7 @@ function ProviderConnection(props: {
|
||||
| { type: "auth.prompt" }
|
||||
| { type: "auth.inputs"; inputs: Record<string, string> }
|
||||
| { type: "auth.pending" }
|
||||
| { type: "auth.complete"; authorization: ProviderAuthAuthorization }
|
||||
| { type: "auth.complete"; authorization: IntegrationOauthConnectOutput["data"] }
|
||||
| { type: "auth.error"; error: string }
|
||||
|
||||
function dispatch(action: Action) {
|
||||
@@ -508,7 +502,7 @@ function ProviderConnection(props: {
|
||||
|
||||
const methodLabel = (value?: { type?: string; label?: string }) => {
|
||||
if (!value) return ""
|
||||
if (value.type === "api") return language.t("provider.connect.method.apiKey")
|
||||
if (value.type === "key") return language.t("provider.connect.method.apiKey")
|
||||
return value.label ?? ""
|
||||
}
|
||||
|
||||
@@ -518,7 +512,7 @@ function ProviderConnection(props: {
|
||||
const hint = suffix?.[1]
|
||||
return {
|
||||
label: suffix ? label.slice(0, -suffix[0].length) : label,
|
||||
hint: hint ? hint[0].toUpperCase() + hint.slice(1) : value?.type === "api" ? "Browser" : undefined,
|
||||
hint: hint ? hint[0].toUpperCase() + hint.slice(1) : value?.type === "key" ? "Browser" : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -549,46 +543,22 @@ function ProviderConnection(props: {
|
||||
const method = methods()[index]
|
||||
dispatch({ type: "method.select", index })
|
||||
|
||||
if (method.type === "api" && method.prompts?.length) {
|
||||
if (!inputs) {
|
||||
dispatch({ type: "auth.prompt" })
|
||||
return
|
||||
}
|
||||
dispatch({ type: "auth.inputs", inputs })
|
||||
return
|
||||
}
|
||||
|
||||
if (method.type === "oauth") {
|
||||
if (method.prompts?.length && !inputs) {
|
||||
dispatch({ type: "auth.prompt" })
|
||||
return
|
||||
}
|
||||
dispatch({ type: "auth.pending" })
|
||||
const start = Date.now()
|
||||
await serverSDK()
|
||||
.client.provider.oauth.authorize(
|
||||
{
|
||||
providerID: props.provider,
|
||||
method: index,
|
||||
inputs,
|
||||
},
|
||||
{ throwOnError: true },
|
||||
)
|
||||
.api.integration.oauth.connect({
|
||||
integrationID: props.provider,
|
||||
methodID: method.id,
|
||||
inputs: inputs ?? {},
|
||||
location: location(),
|
||||
})
|
||||
.then((x) => {
|
||||
if (!alive.value) return
|
||||
const elapsed = Date.now() - start
|
||||
const delay = 1000 - elapsed
|
||||
|
||||
if (delay > 0) {
|
||||
if (timer.current !== undefined) clearTimeout(timer.current)
|
||||
timer.current = setTimeout(() => {
|
||||
timer.current = undefined
|
||||
if (!alive.value) return
|
||||
dispatch({ type: "auth.complete", authorization: x.data! })
|
||||
}, delay)
|
||||
return
|
||||
}
|
||||
dispatch({ type: "auth.complete", authorization: x.data! })
|
||||
dispatch({ type: "auth.complete", authorization: x.data })
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!alive.value) return
|
||||
@@ -603,9 +573,9 @@ function ProviderConnection(props: {
|
||||
index: 0,
|
||||
})
|
||||
|
||||
const prompts = createMemo<NonNullable<ProviderAuthMethod["prompts"]>>(() => {
|
||||
const prompts = createMemo(() => {
|
||||
const value = method()
|
||||
return value?.prompts ?? []
|
||||
return value?.type === "oauth" ? (value.prompts ?? []) : []
|
||||
})
|
||||
const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => {
|
||||
if (!prompt.when) return true
|
||||
@@ -636,10 +606,6 @@ function ProviderConnection(props: {
|
||||
setFormStore("index", next)
|
||||
return
|
||||
}
|
||||
if (method()?.type === "api") {
|
||||
dispatch({ type: "auth.inputs", inputs: value })
|
||||
return
|
||||
}
|
||||
await selectMethod(store.methodIndex, value)
|
||||
}
|
||||
|
||||
@@ -741,7 +707,10 @@ function ProviderConnection(props: {
|
||||
})
|
||||
|
||||
async function complete() {
|
||||
await serverSDK().client.global.dispose()
|
||||
const value = directory()
|
||||
await queryClient
|
||||
.refetchQueries(serverSync().queryOptions.providers(value ? pathKey(value) : null))
|
||||
.catch(() => undefined)
|
||||
dialog.close()
|
||||
showToast({
|
||||
variant: "success",
|
||||
@@ -805,7 +774,7 @@ function ProviderConnection(props: {
|
||||
listRef = ref
|
||||
}}
|
||||
items={methods}
|
||||
key={(m) => m?.label}
|
||||
key={(m) => m?.label ?? m?.type}
|
||||
onSelect={async (selected, index) => {
|
||||
if (!selected) return
|
||||
void selectMethod(index)
|
||||
@@ -851,13 +820,10 @@ function ProviderConnection(props: {
|
||||
}
|
||||
|
||||
setFormStore("error", undefined)
|
||||
await serverSDK().client.auth.set({
|
||||
providerID: props.provider,
|
||||
auth: {
|
||||
type: "api",
|
||||
key: apiKey,
|
||||
...(store.promptInputs ? { metadata: store.promptInputs } : {}),
|
||||
},
|
||||
await serverSDK().api.integration.connect.key({
|
||||
integrationID: props.provider,
|
||||
location: location(),
|
||||
key: apiKey,
|
||||
})
|
||||
await complete()
|
||||
}
|
||||
@@ -984,12 +950,13 @@ function ProviderConnection(props: {
|
||||
|
||||
setFormStore("error", undefined)
|
||||
const result = await serverSDK()
|
||||
.client.provider.oauth.callback({
|
||||
providerID: props.provider,
|
||||
method: store.methodIndex,
|
||||
.api.integration.oauth.complete({
|
||||
integrationID: props.provider,
|
||||
attemptID: store.authorization!.attemptID,
|
||||
location: location(),
|
||||
code,
|
||||
})
|
||||
.then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const }))
|
||||
.then(() => ({ ok: true as const }))
|
||||
.catch((error) => ({ ok: false as const, error }))
|
||||
if (result.ok) {
|
||||
await complete()
|
||||
@@ -1076,25 +1043,37 @@ function ProviderConnection(props: {
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
void (async () => {
|
||||
const poll = async () => {
|
||||
const authorization = store.authorization
|
||||
if (!authorization || !alive.value) return
|
||||
const result = await serverSDK()
|
||||
.client.provider.oauth.callback({
|
||||
providerID: props.provider,
|
||||
method: store.methodIndex,
|
||||
.api.integration.oauth.status({
|
||||
integrationID: props.provider,
|
||||
attemptID: authorization.attemptID,
|
||||
location: location(),
|
||||
})
|
||||
.then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const }))
|
||||
.then((value) => ({ ok: true as const, status: value.data }))
|
||||
.catch((error) => ({ ok: false as const, error }))
|
||||
|
||||
if (!alive.value) return
|
||||
|
||||
if (!result.ok) {
|
||||
const message = formatError(result.error, language.t("common.requestFailed"))
|
||||
dispatch({ type: "auth.error", error: message })
|
||||
dispatch({ type: "auth.error", error: formatError(result.error, language.t("common.requestFailed")) })
|
||||
return
|
||||
}
|
||||
|
||||
await complete()
|
||||
})()
|
||||
if (result.status.status === "complete") {
|
||||
await complete()
|
||||
return
|
||||
}
|
||||
if (result.status.status === "failed") {
|
||||
dispatch({ type: "auth.error", error: result.status.message })
|
||||
return
|
||||
}
|
||||
if (result.status.status === "expired") {
|
||||
dispatch({ type: "auth.error", error: language.t("common.requestFailed") })
|
||||
return
|
||||
}
|
||||
timer.current = setTimeout(poll, 1_000)
|
||||
}
|
||||
void poll()
|
||||
})
|
||||
|
||||
return (
|
||||
@@ -1178,15 +1157,15 @@ function ProviderConnection(props: {
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={method()?.type === "api"}>
|
||||
<Match when={method()?.type === "key"}>
|
||||
<ApiAuthView />
|
||||
</Match>
|
||||
<Match when={method()?.type === "oauth"}>
|
||||
<Switch>
|
||||
<Match when={store.authorization?.method === "code"}>
|
||||
<Match when={store.authorization?.mode === "code"}>
|
||||
<OAuthCodeView />
|
||||
</Match>
|
||||
<Match when={store.authorization?.method === "auto"}>
|
||||
<Match when={store.authorization?.mode === "auto"}>
|
||||
<OAuthAutoView />
|
||||
</Match>
|
||||
</Switch>
|
||||
|
||||
@@ -69,15 +69,11 @@ export const DialogFork: Component = () => {
|
||||
const dir = base64Encode(sdk().directory)
|
||||
|
||||
sdk()
|
||||
.client.session.fork({ sessionID, messageID: item.id })
|
||||
.api.session.fork({ sessionID, messageID: item.id })
|
||||
.then((forked) => {
|
||||
if (!forked.data) {
|
||||
showToast({ title: language.t("common.requestFailed") })
|
||||
return
|
||||
}
|
||||
dialog.close()
|
||||
prompt.set(restored, undefined, { dir, id: forked.data.id })
|
||||
navigate(`/${dir}/session/${forked.data.id}`)
|
||||
prompt.set(restored, undefined, { dir, id: forked.id })
|
||||
navigate(`/${dir}/session/${forked.id}`)
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
} from "./directory-picker-domain"
|
||||
import "./dialog-select-directory-v2.css"
|
||||
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
|
||||
interface DialogSelectDirectoryV2Props {
|
||||
title?: string
|
||||
@@ -67,11 +68,7 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
||||
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
|
||||
const [fallbackPath] = createResource(
|
||||
() => (missingBase() ? true : undefined),
|
||||
() =>
|
||||
sdk.client.path
|
||||
.get()
|
||||
.then((result) => result.data)
|
||||
.catch(() => undefined),
|
||||
() => sdk.api.path.get().catch(() => undefined),
|
||||
{ initialValue: undefined },
|
||||
)
|
||||
const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
|
||||
@@ -85,18 +82,26 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
||||
)
|
||||
const search = createDirectorySearch({ sdk, home, base: () => root() || start() })
|
||||
const [suggestions] = createResource(input, async (value) => {
|
||||
const typed = cleanPickerInput(value).replace(/\/+$/, "")
|
||||
const cleaned = cleanPickerInput(value)
|
||||
const typed = cleaned.replace(/\/+$/, "")
|
||||
const current = displayPickerPath(root(), value, home()).replace(/\/+$/, "")
|
||||
if (!typed || typed === current) return { query: value, items: [] }
|
||||
if (!cleaned || (root() && typed === current)) return { query: value, items: [] }
|
||||
const directories = (await search(value)).map((absolute) => ({ absolute, type: "directory" as const }))
|
||||
if (!policy.includeFiles) return { query: value, items: directories.slice(0, 5) }
|
||||
const files = await sdk.client.find
|
||||
.files({ directory: root(), query: pickerFileSearchQuery(root(), value, home()), type: "file", limit: 20 })
|
||||
.then((result) => result.data ?? [])
|
||||
const base = pickerRoot(cleaned) || root() || start()
|
||||
if (!base) return { query: value, items: directories.slice(0, 5) }
|
||||
const files = await sdk.api.file
|
||||
.find({
|
||||
location: { directory: base },
|
||||
query: pickerFileSearchQuery(base, value, home()),
|
||||
type: "file",
|
||||
limit: 20,
|
||||
})
|
||||
.then((result) => result.data)
|
||||
.catch(() => [])
|
||||
const results = [
|
||||
...directories,
|
||||
...files.map((path) => ({ absolute: absoluteTreePath(root(), path), type: "file" as const })),
|
||||
...files.map((entry) => ({ absolute: absoluteTreePath(base, entry.path), type: "file" as const })),
|
||||
]
|
||||
return {
|
||||
query: value,
|
||||
@@ -115,9 +120,14 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
||||
existing ??
|
||||
loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => {
|
||||
if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined)
|
||||
return sdk.client.file
|
||||
.list({ directory: absolute, path: "" })
|
||||
.then((result) => result.data ?? [])
|
||||
return sdk.api.file
|
||||
.list({ location: { directory: absolute } })
|
||||
.then((result) =>
|
||||
result.data.map((entry) => ({
|
||||
name: getFilename(entry.path.replace(/[\\/]+$/, "")),
|
||||
type: entry.type,
|
||||
})),
|
||||
)
|
||||
.catch(() => undefined)
|
||||
})
|
||||
listings.set(key, request)
|
||||
|
||||
@@ -60,10 +60,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
||||
const [fallbackPath] = createResource(
|
||||
() => (missingBase() ? true : undefined),
|
||||
async () => {
|
||||
return sdk.client.path
|
||||
.get()
|
||||
.then((x) => x.data)
|
||||
.catch(() => undefined)
|
||||
return sdk.api.path.get().catch(() => undefined)
|
||||
},
|
||||
{ initialValue: undefined },
|
||||
)
|
||||
|
||||
@@ -43,7 +43,7 @@ export const DialogSelectMcp: Component = () => {
|
||||
filterKeys={["name", "status"]}
|
||||
sortBy={(a, b) => a.name.localeCompare(b.name)}
|
||||
onSelect={(x) => {
|
||||
if (!x || toggle.isPending) return
|
||||
if (!x || x.status === "pending" || toggle.isPending) return
|
||||
toggle.mutate(x.name)
|
||||
}}
|
||||
>
|
||||
@@ -76,7 +76,7 @@ export const DialogSelectMcp: Component = () => {
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<Switch
|
||||
checked={enabled()}
|
||||
disabled={toggle.isPending && toggle.variables === i.name}
|
||||
disabled={status() === "pending" || (toggle.isPending && toggle.variables === i.name)}
|
||||
onChange={() => {
|
||||
if (toggle.isPending) return
|
||||
toggle.mutate(i.name)
|
||||
|
||||
@@ -133,10 +133,10 @@ test("scopes file autocomplete to the current browser root", () => {
|
||||
test("resolves directory autocomplete from the current browser root", async () => {
|
||||
const directories: string[] = []
|
||||
const sdk = {
|
||||
client: {
|
||||
find: {
|
||||
files: (input: { directory: string }) => {
|
||||
directories.push(input.directory)
|
||||
api: {
|
||||
file: {
|
||||
find: (input: { location?: { directory?: string } }) => {
|
||||
directories.push(input.location?.directory ?? "")
|
||||
return Promise.resolve({ data: [] })
|
||||
},
|
||||
},
|
||||
@@ -152,6 +152,29 @@ test("resolves directory autocomplete from the current browser root", async () =
|
||||
expect(directories).toEqual(["/repo", "/repo/src"])
|
||||
})
|
||||
|
||||
test("searches from an absolute root without a default base", async () => {
|
||||
const directories: string[] = []
|
||||
const sdk = {
|
||||
api: {
|
||||
file: {
|
||||
list: (input: { location?: { directory?: string } }) => {
|
||||
directories.push(input.location?.directory ?? "")
|
||||
return Promise.resolve({
|
||||
data: [
|
||||
{ path: "Users/", type: "directory" },
|
||||
{ path: "tmp/", type: "directory" },
|
||||
],
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
|
||||
const search = createDirectorySearch({ sdk, home: () => "", base: () => undefined })
|
||||
|
||||
expect(await search("/")).toEqual(["/Users", "/tmp"])
|
||||
expect(directories).toEqual(["/"])
|
||||
})
|
||||
|
||||
test("identifies the next directory level to preload", () => {
|
||||
expect(
|
||||
preloadTreeDirectories("src/", [
|
||||
|
||||
@@ -326,15 +326,15 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
|
||||
let current = 0
|
||||
|
||||
const scoped = (value: string) => {
|
||||
const raw = normalizePickerDrive(value)
|
||||
const root = pickerRoot(raw)
|
||||
if (root) return { directory: trimPickerPath(root), path: raw.slice(root.length) }
|
||||
const base = args.base()
|
||||
if (!base) return
|
||||
const raw = normalizePickerDrive(value)
|
||||
if (!raw) return { directory: trimPickerPath(base), path: "" }
|
||||
const home = args.home()
|
||||
if (raw === "~") return { directory: trimPickerPath(home || base), path: "" }
|
||||
if (raw.startsWith("~/")) return { directory: trimPickerPath(home || base), path: raw.slice(2) }
|
||||
const root = pickerRoot(raw)
|
||||
if (root) return { directory: trimPickerPath(root), path: raw.slice(root.length) }
|
||||
return { directory: trimPickerPath(base), path: raw }
|
||||
}
|
||||
|
||||
@@ -342,14 +342,17 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
|
||||
const key = trimPickerPath(directory)
|
||||
const existing = cache.get(key)
|
||||
if (existing) return existing
|
||||
const request = args.sdk.client.file
|
||||
.list({ directory: key, path: "" })
|
||||
.then((result) => result.data ?? [])
|
||||
const request = args.sdk.api.file
|
||||
.list({ location: { directory: key } })
|
||||
.then((result) => result.data)
|
||||
.catch(() => [])
|
||||
.then((nodes) =>
|
||||
nodes
|
||||
.filter((node) => node.type === "directory")
|
||||
.map((node) => ({ name: node.name, absolute: trimPickerPath(normalizePickerDrive(node.absolute)) })),
|
||||
.map((node) => {
|
||||
const relative = trimPickerPath(normalizePickerDrive(node.path))
|
||||
return { name: getFilename(relative), absolute: joinPickerPath(key, relative) }
|
||||
}),
|
||||
)
|
||||
cache.set(key, request)
|
||||
return request
|
||||
@@ -371,9 +374,9 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
|
||||
const pathInput = raw.startsWith("~") || !!pickerRoot(raw) || raw.includes("/")
|
||||
const query = normalizePickerDrive(input.path)
|
||||
if (!pathInput) {
|
||||
const results = await args.sdk.client.find
|
||||
.files({ directory: input.directory, query, type: "directory", limit: 50 })
|
||||
.then((result) => result.data ?? [])
|
||||
const results = await args.sdk.api.file
|
||||
.find({ location: { directory: input.directory }, query, type: "directory", limit: 50 })
|
||||
.then((result) => result.data.map((entry) => entry.path))
|
||||
.catch(() => [])
|
||||
if (!active()) return []
|
||||
return results.map((path) => joinPickerPath(input.directory, path)).slice(0, 50)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { normalizeProjectInfo } from "@/context/global-sync/utils"
|
||||
import { createMemo } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useGlobal } from "@/context/global"
|
||||
@@ -70,13 +71,15 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
|
||||
const start = store.startup.trim()
|
||||
|
||||
if (props.project.id && props.project.id !== "global") {
|
||||
await serverCtx().sdk.client.project.update({
|
||||
const project = await serverCtx().sdk.api.project.update({
|
||||
projectID: props.project.id,
|
||||
directory: props.project.worktree,
|
||||
name,
|
||||
icon: { color: store.color || "", override: store.iconOverride || "" },
|
||||
commands: { start },
|
||||
})
|
||||
serverCtx().sync.set("project", (items) =>
|
||||
items.map((item) => (item.id === project.id ? normalizeProjectInfo(project) : item)),
|
||||
)
|
||||
serverCtx().sync.project.icon(props.project.worktree, store.iconOverride || undefined)
|
||||
dialog.close()
|
||||
return
|
||||
|
||||
@@ -310,7 +310,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
|
||||
)
|
||||
const resources = createMemo(() =>
|
||||
Object.values(sync().data.mcp_resource).map((resource) => ({
|
||||
id: `resource:${resource.client}:${resource.uri}`,
|
||||
id: `resource:${resource.server}:${resource.uri}`,
|
||||
kind: "resource" as const,
|
||||
label: `@${resource.name}`,
|
||||
path: resource.uri,
|
||||
@@ -327,7 +327,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
|
||||
source: {
|
||||
type: "resource" as const,
|
||||
text: { value: `@${resource.name}`, start: 0, end: resource.name.length + 1 },
|
||||
clientName: resource.client,
|
||||
clientName: resource.server,
|
||||
uri: resource.uri,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -591,7 +591,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
type: "resource",
|
||||
name: resource.name,
|
||||
uri: resource.uri,
|
||||
client: resource.client,
|
||||
client: resource.server,
|
||||
display: resource.name,
|
||||
description: resource.description,
|
||||
mime: resource.mimeType,
|
||||
|
||||
@@ -7,6 +7,11 @@ let createPromptSubmit: typeof import("./submit").createPromptSubmit
|
||||
|
||||
const createdClients: string[] = []
|
||||
const createdSessions: string[] = []
|
||||
const sessionCreateInputs: Array<{
|
||||
agent?: string
|
||||
model?: { id: string; providerID: string; variant?: string }
|
||||
location?: { directory: string }
|
||||
}> = []
|
||||
const enabledAutoAccept: Array<{ server: string; sessionID: string; directory: string }> = []
|
||||
const optimistic: Array<{
|
||||
directory?: string
|
||||
@@ -20,9 +25,14 @@ const optimistic: Array<{
|
||||
const optimisticSeeded: boolean[] = []
|
||||
const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
|
||||
const promoted: Array<{ directory: string; sessionID: string }> = []
|
||||
const sentShell: string[] = []
|
||||
const sentShell: Array<{ sessionID: string; id?: string; command: string }> = []
|
||||
const syncedDirectories: string[] = []
|
||||
const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string }> = []
|
||||
const sentPrompts: string[] = []
|
||||
const promptInputs: unknown[] = []
|
||||
const sentCommands: unknown[] = []
|
||||
const commands: Array<{ name: string }> = []
|
||||
let serverSessionSyncs = 0
|
||||
|
||||
let params: { id?: string } = {}
|
||||
let search: { draftId?: string } = {}
|
||||
@@ -31,7 +41,7 @@ let variant: string | undefined
|
||||
let permissionServer = "server-a"
|
||||
let createSessionGate: Promise<void> | undefined
|
||||
|
||||
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
||||
let promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
||||
const [promptStore, setPromptStore] = createStore<PromptStore>({
|
||||
prompt: promptValue,
|
||||
cursor: 0,
|
||||
@@ -63,23 +73,39 @@ const prompt = {
|
||||
const clientFor = (directory: string) => {
|
||||
createdClients.push(directory)
|
||||
return {
|
||||
session: {
|
||||
create: async () => {
|
||||
await createSessionGate
|
||||
createdSessions.push(directory)
|
||||
return {
|
||||
data: {
|
||||
api: {
|
||||
session: {
|
||||
create: async (input: (typeof sessionCreateInputs)[number]) => {
|
||||
await createSessionGate
|
||||
const location = input.location?.directory ?? directory
|
||||
createdSessions.push(location)
|
||||
sessionCreateInputs.push(input)
|
||||
return {
|
||||
id: `session-${createdSessions.length}`,
|
||||
projectID: "project",
|
||||
agent: input.agent,
|
||||
model: input.model,
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1, updated: 1 },
|
||||
title: `New session ${createdSessions.length}`,
|
||||
},
|
||||
}
|
||||
location: { directory: location },
|
||||
}
|
||||
},
|
||||
prompt: async (input: unknown) => {
|
||||
sentPrompts.push(directory)
|
||||
promptInputs.push(input)
|
||||
return { data: undefined }
|
||||
},
|
||||
command: async (input: unknown) => {
|
||||
sentCommands.push(input)
|
||||
},
|
||||
shell: async (input: { sessionID: string; id?: string; command: string }) => {
|
||||
sentShell.push(input)
|
||||
},
|
||||
},
|
||||
shell: async () => {
|
||||
sentShell.push(directory)
|
||||
return { data: undefined }
|
||||
},
|
||||
prompt: async () => ({ data: undefined }),
|
||||
promptAsync: async () => ({ data: undefined }),
|
||||
},
|
||||
session: {
|
||||
command: async () => ({ data: undefined }),
|
||||
abort: async () => ({ data: undefined }),
|
||||
},
|
||||
@@ -172,6 +198,7 @@ beforeAll(async () => {
|
||||
scope: "local",
|
||||
directory: "/repo/main",
|
||||
client: rootClient,
|
||||
api: rootClient.api,
|
||||
url: "http://localhost:4096",
|
||||
createClient(opts: any) {
|
||||
return clientFor(opts.directory)
|
||||
@@ -183,7 +210,7 @@ beforeAll(async () => {
|
||||
|
||||
mock.module("@/context/sync", () => ({
|
||||
useSync: () => () => ({
|
||||
data: { command: [] },
|
||||
data: { command: commands },
|
||||
session: {
|
||||
optimistic: {
|
||||
add: (value: {
|
||||
@@ -210,6 +237,9 @@ beforeAll(async () => {
|
||||
session: {
|
||||
remember: () => undefined,
|
||||
set: () => undefined,
|
||||
sync: async () => {
|
||||
serverSessionSyncs++
|
||||
},
|
||||
},
|
||||
child: (directory: string) => {
|
||||
syncedDirectories.push(directory)
|
||||
@@ -251,11 +281,17 @@ beforeAll(async () => {
|
||||
beforeEach(() => {
|
||||
createdClients.length = 0
|
||||
createdSessions.length = 0
|
||||
sessionCreateInputs.length = 0
|
||||
enabledAutoAccept.length = 0
|
||||
optimistic.length = 0
|
||||
optimisticSeeded.length = 0
|
||||
promoted.length = 0
|
||||
promotedDrafts.length = 0
|
||||
sentPrompts.length = 0
|
||||
promptInputs.length = 0
|
||||
sentCommands.length = 0
|
||||
commands.length = 0
|
||||
promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
||||
params = {}
|
||||
search = {}
|
||||
sentShell.length = 0
|
||||
@@ -264,6 +300,7 @@ beforeEach(() => {
|
||||
variant = undefined
|
||||
permissionServer = "server-a"
|
||||
createSessionGate = undefined
|
||||
serverSessionSyncs = 0
|
||||
for (const key of Object.keys(storedSessions)) delete storedSessions[key]
|
||||
})
|
||||
|
||||
@@ -297,8 +334,24 @@ describe("prompt submit worktree selection", () => {
|
||||
|
||||
expect(createdClients).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
||||
expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
||||
expect(sentShell).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
||||
expect(sessionCreateInputs).toEqual([
|
||||
{
|
||||
agent: "agent",
|
||||
model: { id: "model", providerID: "provider", variant: undefined },
|
||||
location: { directory: "/repo/worktree-a" },
|
||||
},
|
||||
{
|
||||
agent: "agent",
|
||||
model: { id: "model", providerID: "provider", variant: undefined },
|
||||
location: { directory: "/repo/worktree-b" },
|
||||
},
|
||||
])
|
||||
expect(sentShell).toEqual([
|
||||
expect.objectContaining({ sessionID: "session-1", id: expect.stringMatching(/^evt_/), command: "ls" }),
|
||||
expect.objectContaining({ sessionID: "session-2", id: expect.stringMatching(/^evt_/), command: "ls" }),
|
||||
])
|
||||
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"])
|
||||
expect(serverSessionSyncs).toBe(0)
|
||||
expect(promoted).toEqual([
|
||||
{ directory: "/repo/worktree-a", sessionID: "session-1" },
|
||||
{ directory: "/repo/worktree-b", sessionID: "session-2" },
|
||||
@@ -419,6 +472,7 @@ describe("prompt submit worktree selection", () => {
|
||||
const event = { preventDefault: () => undefined } as unknown as Event
|
||||
|
||||
await submit.handleSubmit(event)
|
||||
await Bun.sleep(0)
|
||||
|
||||
expect(optimistic).toHaveLength(1)
|
||||
expect(optimistic[0]).toMatchObject({
|
||||
@@ -427,6 +481,53 @@ describe("prompt submit worktree selection", () => {
|
||||
model: { providerID: "provider", modelID: "model", variant: "high" },
|
||||
},
|
||||
})
|
||||
expect(sentPrompts).toEqual(["/repo/main"])
|
||||
expect(promptInputs[0]).toMatchObject({
|
||||
sessionID: "session-1",
|
||||
text: "ls",
|
||||
files: [],
|
||||
agents: [],
|
||||
})
|
||||
expect((promptInputs[0] as { id?: string }).id).toStartWith("msg_")
|
||||
})
|
||||
|
||||
test("submits slash commands through the current session API", async () => {
|
||||
params = { id: "session-1" }
|
||||
variant = "high"
|
||||
commands.push({ name: "review" })
|
||||
promptValue = [{ type: "text", content: "/review staged changes", start: 0, end: 22 }]
|
||||
|
||||
const submit = createPromptSubmit({
|
||||
prompt,
|
||||
info: () => ({ id: "session-1" }),
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
autoAccept: () => false,
|
||||
mode: () => "normal",
|
||||
working: () => false,
|
||||
editor: () => undefined,
|
||||
queueScroll: () => undefined,
|
||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||
addToHistory: () => undefined,
|
||||
resetHistoryNavigation: () => undefined,
|
||||
setMode: () => undefined,
|
||||
setPopover: () => undefined,
|
||||
})
|
||||
|
||||
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
|
||||
|
||||
expect(sentCommands).toEqual([
|
||||
{
|
||||
sessionID: "session-1",
|
||||
id: expect.stringMatching(/^msg_/),
|
||||
command: "review",
|
||||
arguments: "staged changes",
|
||||
agent: "agent",
|
||||
model: { id: "model", providerID: "provider", variant: "high" },
|
||||
files: [],
|
||||
},
|
||||
])
|
||||
expect(serverSessionSyncs).toBe(0)
|
||||
})
|
||||
|
||||
test("uses an injected model selection", async () => {
|
||||
@@ -487,7 +588,8 @@ describe("prompt submit worktree selection", () => {
|
||||
|
||||
await submit.handleSubmit(event)
|
||||
|
||||
expect(storedSessions["/repo/worktree-a"]).toEqual([{ id: "session-1", title: "New session 1" }])
|
||||
expect(storedSessions["/repo/worktree-a"]).toHaveLength(1)
|
||||
expect(storedSessions["/repo/worktree-a"]?.[0]).toMatchObject({ id: "session-1", title: "New session 1" })
|
||||
expect(optimisticSeeded).toEqual([true])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -20,6 +20,8 @@ import { setCursorPosition } from "./editor-dom"
|
||||
import { formatServerError } from "@/utils/server-errors"
|
||||
import { ScopedKey } from "@/utils/server-scope"
|
||||
import { createPromptSubmissionState } from "./submission-state"
|
||||
import { normalizeSessionInfo } from "@/utils/session"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
|
||||
type PendingPrompt = {
|
||||
abort: AbortController
|
||||
@@ -39,7 +41,7 @@ export type FollowupDraft = {
|
||||
}
|
||||
|
||||
type FollowupSendInput = {
|
||||
client: DirectorySDK["client"]
|
||||
api: DirectorySDK["api"]["session"]
|
||||
serverSync: ServerSync
|
||||
sync: DirectorySync
|
||||
draft: FollowupDraft
|
||||
@@ -81,19 +83,21 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||
return false
|
||||
}
|
||||
|
||||
await input.client.session.command({
|
||||
const messageID = Identifier.ascending("message")
|
||||
await input.api.command({
|
||||
sessionID: input.draft.sessionID,
|
||||
id: messageID,
|
||||
command: cmd,
|
||||
arguments: tail.join(" "),
|
||||
agent: input.draft.agent,
|
||||
model: `${input.draft.model.providerID}/${input.draft.model.modelID}`,
|
||||
variant: input.draft.variant,
|
||||
parts: images.map((attachment) => ({
|
||||
id: Identifier.ascending("part"),
|
||||
type: "file" as const,
|
||||
mime: attachment.mime,
|
||||
url: attachment.dataUrl,
|
||||
filename: attachment.filename,
|
||||
model: {
|
||||
id: input.draft.model.modelID,
|
||||
providerID: input.draft.model.providerID,
|
||||
variant: input.draft.variant,
|
||||
},
|
||||
files: images.map((attachment) => ({
|
||||
uri: attachment.dataUrl,
|
||||
name: attachment.filename,
|
||||
})),
|
||||
})
|
||||
return true
|
||||
@@ -152,13 +156,36 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||
return false
|
||||
}
|
||||
|
||||
await input.client.session.promptAsync({
|
||||
await input.api.prompt({
|
||||
sessionID: input.draft.sessionID,
|
||||
id: messageID,
|
||||
agent: input.draft.agent,
|
||||
model: input.draft.model,
|
||||
messageID,
|
||||
parts: requestParts,
|
||||
variant: input.draft.variant,
|
||||
text: requestParts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"),
|
||||
files: requestParts.flatMap((part) => {
|
||||
if (part.type !== "file") return []
|
||||
const text = part.source?.text
|
||||
return [
|
||||
{
|
||||
uri: part.url,
|
||||
name: part.filename,
|
||||
mention: text ? { start: text.start, end: text.end, text: text.value } : undefined,
|
||||
},
|
||||
]
|
||||
}),
|
||||
agents: requestParts.flatMap((part) =>
|
||||
part.type === "agent"
|
||||
? [
|
||||
{
|
||||
name: part.name,
|
||||
mention: part.source
|
||||
? { start: part.source.start, end: part.source.end, text: part.source.value }
|
||||
: undefined,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
),
|
||||
})
|
||||
return true
|
||||
} catch (err) {
|
||||
@@ -210,6 +237,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
const pendingKey = (sessionID: string) => ScopedKey.from(sdk().scope, sessionID)
|
||||
|
||||
const errorMessage = (err: unknown) => {
|
||||
if (err && typeof err === "object" && "message" in err && typeof err.message === "string") return err.message
|
||||
if (err && typeof err === "object" && "data" in err) {
|
||||
const data = (err as { data?: { message?: string } }).data
|
||||
if (data?.message) return data.message
|
||||
@@ -235,9 +263,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return sdk()
|
||||
.client.session.abort({
|
||||
sessionID,
|
||||
})
|
||||
.api.session.interrupt({ sessionID })
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
@@ -364,9 +390,13 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
|
||||
let session = input.info()
|
||||
if (!session && isNewSession) {
|
||||
const created = await client.session
|
||||
.create()
|
||||
.then((x) => x.data ?? undefined)
|
||||
const created = await sdk()
|
||||
.api.session.create({
|
||||
agent: currentAgent.name,
|
||||
model: { id: currentModel.id, providerID: currentModel.provider.id, variant },
|
||||
location: { directory: sessionDirectory },
|
||||
})
|
||||
.then(normalizeSessionInfo)
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.sessionCreateFailed.title"),
|
||||
@@ -450,12 +480,14 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
|
||||
if (mode === "shell") {
|
||||
clearInput()
|
||||
client.session
|
||||
.shell({
|
||||
const eventID = Event.ID.create()
|
||||
sdk()
|
||||
.api.session.shell({
|
||||
sessionID: session.id,
|
||||
id: eventID,
|
||||
command: text,
|
||||
agent,
|
||||
model,
|
||||
command: text,
|
||||
})
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
@@ -473,23 +505,23 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
const customCommand = sync().data.command.find((c) => c.name === commandName)
|
||||
if (customCommand) {
|
||||
clearInput()
|
||||
client.session
|
||||
.command({
|
||||
const messageID = Identifier.ascending("message")
|
||||
serverSync().session.set("session_status", session.id, { type: "busy" })
|
||||
sdk()
|
||||
.api.session.command({
|
||||
sessionID: session.id,
|
||||
id: messageID,
|
||||
command: commandName,
|
||||
arguments: args.join(" "),
|
||||
agent,
|
||||
model: `${model.providerID}/${model.modelID}`,
|
||||
variant,
|
||||
parts: images.map((attachment) => ({
|
||||
id: Identifier.ascending("part"),
|
||||
type: "file" as const,
|
||||
mime: attachment.mime,
|
||||
url: attachment.dataUrl,
|
||||
filename: attachment.filename,
|
||||
model: { id: model.modelID, providerID: model.providerID, variant },
|
||||
files: images.map((attachment) => ({
|
||||
uri: attachment.dataUrl,
|
||||
name: attachment.filename,
|
||||
})),
|
||||
})
|
||||
.catch((err) => {
|
||||
serverSync().session.set("session_status", session.id, { type: "idle" })
|
||||
showToast({
|
||||
title: language.t("prompt.toast.commandSendFailed.title"),
|
||||
description: formatServerError(err, language.t, language.t("common.requestFailed")),
|
||||
@@ -573,7 +605,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
}
|
||||
|
||||
void sendFollowupDraft({
|
||||
client,
|
||||
api: sdk().api.session,
|
||||
sync: sync(),
|
||||
serverSync: serverSync(),
|
||||
draft,
|
||||
|
||||
@@ -15,9 +15,47 @@ export const ServerRowMenu: Component<{
|
||||
}> = (props) => {
|
||||
const language = useLanguage()
|
||||
const key = ServerConnection.key(props.server)
|
||||
const builtin = ServerConnection.builtin(props.server)
|
||||
const isDefault = () => props.controller.defaultKey() === key
|
||||
return (
|
||||
<ServerRowMenuView
|
||||
server={props.server}
|
||||
labels={serverMenuLabels(language)}
|
||||
canDefault={props.controller.canDefault()}
|
||||
isDefault={props.controller.defaultKey() === key}
|
||||
onEdit={props.onEdit}
|
||||
onSetDefault={() => props.controller.setDefault(key)}
|
||||
onRemoveDefault={() => props.controller.setDefault(null)}
|
||||
onRemove={() => props.controller.handleRemove(key)}
|
||||
open={props.open}
|
||||
onOpenChange={props.onOpenChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function serverMenuLabels(language: ReturnType<typeof useLanguage>) {
|
||||
return {
|
||||
more: language.t("common.moreOptions"),
|
||||
server: language.t("settings.section.server"),
|
||||
edit: language.t("dialog.server.menu.edit"),
|
||||
default: language.t("dialog.server.menu.default"),
|
||||
defaultRemove: language.t("dialog.server.menu.defaultRemove"),
|
||||
delete: language.t("dialog.server.menu.delete"),
|
||||
}
|
||||
}
|
||||
|
||||
export const ServerRowMenuView: Component<{
|
||||
server: ServerConnection.Any
|
||||
labels: ReturnType<typeof serverMenuLabels>
|
||||
canDefault: boolean
|
||||
isDefault: boolean
|
||||
onEdit: (server: ServerConnection.Http) => void
|
||||
onSetDefault: () => void
|
||||
onRemoveDefault: () => void
|
||||
onRemove: () => void
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}> = (props) => {
|
||||
const builtin = () => ServerConnection.builtin(props.server)
|
||||
const httpServer = () => (props.server.type === "http" ? props.server : undefined)
|
||||
return (
|
||||
<MenuV2 gutter={6} modal={false} placement="bottom-end" open={props.open} onOpenChange={props.onOpenChange}>
|
||||
<MenuV2.Trigger
|
||||
@@ -25,31 +63,30 @@ export const ServerRowMenu: Component<{
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
icon={<IconV2 name="outline-dots" />}
|
||||
aria-label={language.t("common.moreOptions")}
|
||||
aria-label={props.labels.more}
|
||||
/>
|
||||
<MenuV2.Portal>
|
||||
<MenuV2.Content>
|
||||
<MenuV2.Group>
|
||||
<MenuV2.GroupLabel>{language.t("settings.section.server")}</MenuV2.GroupLabel>
|
||||
<MenuV2.GroupLabel>{props.labels.server}</MenuV2.GroupLabel>
|
||||
<MenuV2.Item
|
||||
disabled={builtin || props.server.type !== "http"}
|
||||
onSelect={() => props.onEdit(props.server as ServerConnection.Http)}
|
||||
disabled={builtin() || !httpServer()}
|
||||
onSelect={() => {
|
||||
const server = httpServer()
|
||||
if (server) props.onEdit(server)
|
||||
}}
|
||||
>
|
||||
{language.t("dialog.server.menu.edit")}
|
||||
{props.labels.edit}
|
||||
</MenuV2.Item>
|
||||
<Show when={props.controller.canDefault() && !isDefault()}>
|
||||
<MenuV2.Item onSelect={() => props.controller.setDefault(key)}>
|
||||
{language.t("dialog.server.menu.default")}
|
||||
</MenuV2.Item>
|
||||
<Show when={props.canDefault && !props.isDefault}>
|
||||
<MenuV2.Item onSelect={props.onSetDefault}>{props.labels.default}</MenuV2.Item>
|
||||
</Show>
|
||||
<Show when={props.controller.canDefault() && isDefault()}>
|
||||
<MenuV2.Item onSelect={() => props.controller.setDefault(null)}>
|
||||
{language.t("dialog.server.menu.defaultRemove")}
|
||||
</MenuV2.Item>
|
||||
<Show when={props.canDefault && props.isDefault}>
|
||||
<MenuV2.Item onSelect={props.onRemoveDefault}>{props.labels.defaultRemove}</MenuV2.Item>
|
||||
</Show>
|
||||
<MenuV2.Separator />
|
||||
<MenuV2.Item disabled={builtin} onSelect={() => props.controller.handleRemove(key)}>
|
||||
{language.t("dialog.server.menu.delete")}
|
||||
<MenuV2.Item disabled={builtin()} onSelect={props.onRemove}>
|
||||
{props.labels.delete}
|
||||
</MenuV2.Item>
|
||||
</MenuV2.Group>
|
||||
</MenuV2.Content>
|
||||
|
||||
@@ -26,7 +26,7 @@ describe("hasNonBlockingServiceIssue", () => {
|
||||
expect(hasNonBlockingServiceIssue({ mcp: ["failed"], lsp: [] })).toBe(true)
|
||||
expect(hasNonBlockingServiceIssue({ mcp: ["needs_auth"], lsp: [] })).toBe(true)
|
||||
expect(hasNonBlockingServiceIssue({ mcp: ["needs_client_registration"], lsp: [] })).toBe(true)
|
||||
expect(hasNonBlockingServiceIssue({ mcp: ["connected", "disabled"], lsp: [] })).toBe(false)
|
||||
expect(hasNonBlockingServiceIssue({ mcp: ["connected", "pending", "disabled"], lsp: [] })).toBe(false)
|
||||
})
|
||||
|
||||
test("detects LSP failures that do not block chatting", () => {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { LspStatus, McpStatus } from "@opencode-ai/sdk/v2/client"
|
||||
import type { LspStatus } from "@opencode-ai/sdk/v2/client"
|
||||
import type { McpServer } from "@opencode-ai/client/promise"
|
||||
|
||||
export function hasNonBlockingServiceIssue(input: {
|
||||
mcp: Array<McpStatus["status"]>
|
||||
mcp: Array<McpServer["status"]["status"]>
|
||||
lsp: Array<LspStatus["status"]>
|
||||
}) {
|
||||
return (
|
||||
input.mcp.some((status) => status !== "connected" && status !== "disabled") ||
|
||||
input.mcp.some((status) => status !== "connected" && status !== "pending" && status !== "disabled") ||
|
||||
input.lsp.some((status) => status === "error")
|
||||
)
|
||||
}
|
||||
|
||||
@@ -120,8 +120,7 @@ export function TabNavItem(props: {
|
||||
const ctx = serverCtx()
|
||||
const session = props.session()
|
||||
if (!ctx || !session) return
|
||||
const client = ctx.sdk.createClient({ directory: session.directory, throwOnError: true })
|
||||
await client.session.update({ sessionID: session.id, title })
|
||||
await ctx.sdk.api.session.rename({ sessionID: session.id, title })
|
||||
}
|
||||
|
||||
const closeRename = async (save: boolean) => {
|
||||
|
||||
@@ -28,6 +28,7 @@ import { tabKey, useTabs } from "@/context/tabs"
|
||||
import type { PromptSession } from "@/context/prompt"
|
||||
import "./titlebar.css"
|
||||
import { newTabTooltipKeybind } from "./command-tooltip-keybind"
|
||||
import { normalizeSessionInfo } from "@/utils/session"
|
||||
|
||||
type TauriDesktopWindow = {
|
||||
startDragging?: () => Promise<void>
|
||||
@@ -267,9 +268,9 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
||||
return conn ? { route, sdk: global.ensureServerCtx(conn).sdk } : undefined
|
||||
},
|
||||
({ route, sdk }) =>
|
||||
sdk.client.session
|
||||
sdk.api.session
|
||||
.get({ sessionID: route.sessionId })
|
||||
.then((x) => x.data)
|
||||
.then(normalizeSessionInfo)
|
||||
.catch(() => {}),
|
||||
)
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { produce, reconcile, type SetStoreFunction } from "solid-js/store"
|
||||
import type { createServerSdkContext } from "./server-sdk"
|
||||
import type { createServerSyncContextInner } from "./server-sync"
|
||||
import type { State } from "./global-sync/types"
|
||||
import { normalizeSessionInfo } from "@/utils/session"
|
||||
|
||||
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||
const sessionFields = new Set([
|
||||
@@ -15,6 +16,7 @@ const sessionFields = new Set([
|
||||
"permission",
|
||||
"question",
|
||||
"message",
|
||||
"session_message",
|
||||
"part",
|
||||
"part_text_accum_delta",
|
||||
])
|
||||
@@ -114,7 +116,6 @@ export const createDirSyncContext = (
|
||||
await serverSync.session.sync(sessionID, options)
|
||||
index(sessionID)
|
||||
},
|
||||
diff: serverSync.session.diff,
|
||||
todo: serverSync.session.todo,
|
||||
history: serverSync.session.history,
|
||||
evict(sessionID: string) {
|
||||
@@ -123,9 +124,9 @@ export const createDirSyncContext = (
|
||||
fetch: async (count = 10) => {
|
||||
const [store, setStore] = current()
|
||||
setStore("limit", (value) => value + count)
|
||||
const response = await client.session.list()
|
||||
const sessions = (response.data ?? [])
|
||||
.filter((session) => !!session?.id)
|
||||
const response = await serverSDK.api.session.list({ directory, limit: store.limit, order: "desc" })
|
||||
const sessions = response.data
|
||||
.map(normalizeSessionInfo)
|
||||
.sort((a, b) => cmp(a.id, b.id))
|
||||
.slice(0, store.limit)
|
||||
sessions.forEach(serverSync.session.remember)
|
||||
@@ -133,7 +134,7 @@ export const createDirSyncContext = (
|
||||
},
|
||||
more: createMemo(() => current()[0].session.length >= current()[0].limit),
|
||||
archive: async (sessionID: string) => {
|
||||
await serverSDK.client.session.update({ sessionID, time: { archived: Date.now() } })
|
||||
await serverSDK.api.session.archive({ sessionID, directory })
|
||||
current()[1](
|
||||
"session",
|
||||
produce((draft) => {
|
||||
|
||||
@@ -204,10 +204,18 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||
}
|
||||
|
||||
const search = (query: string, dirs: "true" | "false", options?: { limit?: number; signal?: AbortSignal }) =>
|
||||
sdk()
|
||||
.client.find.files({ query, dirs, limit: options?.limit }, { signal: options?.signal })
|
||||
serverSDK()
|
||||
.api.file.find(
|
||||
{
|
||||
location: { directory: sdk().directory },
|
||||
query,
|
||||
type: dirs === "true" ? "directory" : "file",
|
||||
limit: options?.limit,
|
||||
},
|
||||
{ signal: options?.signal },
|
||||
)
|
||||
.then(
|
||||
(x) => (x.data ?? []).map(path.normalize),
|
||||
(x) => x.data.map((entry) => path.normalize(entry.path)),
|
||||
(error) => {
|
||||
if (options?.signal?.aborted) throw error
|
||||
return []
|
||||
|
||||
@@ -1,14 +1,39 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { QueryClient } from "@tanstack/solid-query"
|
||||
import type { Config, OpencodeClient, Project, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Config, OpencodeClient, Project } from "@opencode-ai/sdk/v2/client"
|
||||
import type { AgentApi, CatalogApi, CommandApi, ProjectApi, ReferenceApi } from "@opencode-ai/client/promise"
|
||||
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||
import { bootstrapDirectory, loadPathQuery, loadProvidersQuery } from "./bootstrap"
|
||||
import {
|
||||
bootstrapDirectory,
|
||||
loadAgentsQuery,
|
||||
loadCommands,
|
||||
loadPathQuery,
|
||||
loadProjectsQuery,
|
||||
loadProvidersQuery,
|
||||
loadReferencesQuery,
|
||||
} from "./bootstrap"
|
||||
import type { State, VcsCache } from "./types"
|
||||
import { createServerSession } from "../server-session"
|
||||
import { ServerScope } from "@/utils/server-scope"
|
||||
import type { ServerApi } from "@/utils/server"
|
||||
|
||||
const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
|
||||
const api = {
|
||||
agent: { list: async () => ({ location: {}, data: [] }) },
|
||||
provider: { list: async () => ({ location: {}, data: [] }) },
|
||||
model: {
|
||||
list: async () => ({ location: {}, data: [] }),
|
||||
default: async () => ({ location: {}, data: null }),
|
||||
},
|
||||
permission: { request: { list: async () => ({ location: {}, data: [] }) } },
|
||||
project: {
|
||||
list: async () => [],
|
||||
current: async () => ({ id: "project", directory: "/project" }),
|
||||
},
|
||||
question: { request: { list: async () => ({ location: {}, data: [] }) } },
|
||||
reference: { list: async () => ({ location: {}, data: [] }) },
|
||||
vcs: { get: async () => ({ location: {}, data: {} }) },
|
||||
} as unknown as ServerApi
|
||||
|
||||
function directoryState() {
|
||||
return createStore<State>({
|
||||
@@ -41,6 +66,7 @@ function directoryState() {
|
||||
vcs: undefined,
|
||||
limit: 5,
|
||||
message: {},
|
||||
session_message: {},
|
||||
part: {},
|
||||
part_text_accum_delta: {},
|
||||
})
|
||||
@@ -64,7 +90,6 @@ describe("bootstrapDirectory", () => {
|
||||
sdk: {
|
||||
app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) },
|
||||
config: { get: async () => ({ data: {} }) },
|
||||
session: { status: async () => ({ data: {} }) },
|
||||
vcs: { get: async () => ({ data: undefined }) },
|
||||
command: {
|
||||
list: async () => {
|
||||
@@ -83,6 +108,7 @@ describe("bootstrapDirectory", () => {
|
||||
},
|
||||
provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) },
|
||||
} as unknown as OpencodeClient,
|
||||
api,
|
||||
store,
|
||||
setStore,
|
||||
vcsCache: { setStore() {} } as unknown as VcsCache,
|
||||
@@ -98,79 +124,108 @@ describe("bootstrapDirectory", () => {
|
||||
expect(store.status).toBe("complete")
|
||||
expect(mcpReads).toEqual([])
|
||||
})
|
||||
|
||||
test("seeds session status even while warming session info stalls", async () => {
|
||||
const [store, setStore] = directoryState()
|
||||
const stalled = Promise.withResolvers<never>()
|
||||
const client = {
|
||||
app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) },
|
||||
config: { get: async () => ({ data: {} }) },
|
||||
session: {
|
||||
status: async () => ({ data: { ses_busy: { type: "busy" } } }),
|
||||
get: () => stalled.promise,
|
||||
},
|
||||
vcs: { get: async () => ({ data: undefined }) },
|
||||
command: { list: async () => ({ data: [] }) },
|
||||
permission: { list: async () => ({ data: [] }) },
|
||||
question: { list: async () => ({ data: [] }) },
|
||||
v2: { reference: { list: async () => ({ data: { data: [] } }) } },
|
||||
mcp: { status: async () => ({ data: {} }) },
|
||||
provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) },
|
||||
} as unknown as OpencodeClient
|
||||
const session = createServerSession(client)
|
||||
const stale: Session = {
|
||||
id: "ses_stale",
|
||||
slug: "ses_stale",
|
||||
projectID: "project",
|
||||
directory: "/project",
|
||||
title: "stale",
|
||||
version: "1",
|
||||
time: { created: 1, updated: 1 },
|
||||
}
|
||||
session.remember(stale)
|
||||
session.set("session_status", stale.id, { type: "busy" })
|
||||
|
||||
await bootstrapDirectory({
|
||||
directory: "/project",
|
||||
scope: ServerScope.local,
|
||||
mcp: false,
|
||||
global: {
|
||||
config: {} satisfies Config,
|
||||
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
|
||||
project: [{ id: "project", worktree: "/project" } as Project],
|
||||
provider,
|
||||
},
|
||||
sdk: client,
|
||||
store,
|
||||
setStore,
|
||||
vcsCache: { setStore() {} } as unknown as VcsCache,
|
||||
loadSessions() {},
|
||||
translate: (key) => key,
|
||||
queryClient: new QueryClient(),
|
||||
session,
|
||||
})
|
||||
|
||||
const deadline = Date.now() + 500
|
||||
while (!session.data.session_working("ses_busy") && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
}
|
||||
|
||||
expect(session.data.session_status["ses_busy"]?.type).toBe("busy")
|
||||
expect(session.data.session_status[stale.id]).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("query keys", () => {
|
||||
test("partitions identical directories by server scope", () => {
|
||||
const client = {} as OpencodeClient
|
||||
const client = {} as Parameters<typeof loadPathQuery>[2]
|
||||
const api = {} as CatalogApi
|
||||
const remote = "https://debian.example" as typeof ServerScope.local
|
||||
|
||||
expect([...loadPathQuery(ServerScope.local, "/repo", client).queryKey]).toEqual(["local", "/repo", "path"])
|
||||
expect([...loadPathQuery(remote, "/repo", client).queryKey]).toEqual(["https://debian.example", "/repo", "path"])
|
||||
expect([...loadProvidersQuery(remote, null, client).queryKey]).toEqual([
|
||||
"https://debian.example",
|
||||
null,
|
||||
"providers",
|
||||
expect([...loadProvidersQuery(remote, null, api).queryKey]).toEqual(["https://debian.example", null, "providers"])
|
||||
})
|
||||
|
||||
test("loads the current provider and model catalog", async () => {
|
||||
const calls: unknown[] = []
|
||||
const api = {
|
||||
provider: {
|
||||
list: async (input: unknown) => {
|
||||
calls.push(["provider", input])
|
||||
return { location: {}, data: [{ id: "openai", name: "OpenAI", package: "@ai-sdk/openai" }] }
|
||||
},
|
||||
},
|
||||
model: {
|
||||
list: async (input: unknown) => {
|
||||
calls.push(["model", input])
|
||||
return { location: {}, data: [] }
|
||||
},
|
||||
default: async (input: unknown) => {
|
||||
calls.push(["default", input])
|
||||
return { location: {}, data: null }
|
||||
},
|
||||
},
|
||||
} as unknown as CatalogApi
|
||||
|
||||
const result = await new QueryClient().fetchQuery(loadProvidersQuery(ServerScope.local, "/repo", api))
|
||||
|
||||
expect(calls).toEqual([
|
||||
["provider", { location: { directory: "/repo" } }],
|
||||
["model", { location: { directory: "/repo" } }],
|
||||
["default", { location: { directory: "/repo" } }],
|
||||
])
|
||||
expect(result.connected).toEqual(["openai"])
|
||||
})
|
||||
|
||||
test("loads agents from the current location-scoped endpoint", async () => {
|
||||
const calls: unknown[] = []
|
||||
const api = {
|
||||
list: async (input: unknown) => {
|
||||
calls.push(input)
|
||||
return { location: {}, data: [] }
|
||||
},
|
||||
} as unknown as AgentApi
|
||||
|
||||
const result = await new QueryClient().fetchQuery(loadAgentsQuery(ServerScope.local, "/repo", api))
|
||||
|
||||
expect(calls).toEqual([{ location: { directory: "/repo" } }])
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
test("loads commands from the current location-scoped endpoint", async () => {
|
||||
const calls: unknown[] = []
|
||||
const api = {
|
||||
list: async (input: unknown) => {
|
||||
calls.push(input)
|
||||
return {
|
||||
location: {},
|
||||
data: [{ name: "review", template: "Review files", source: "command" as const }],
|
||||
}
|
||||
},
|
||||
} as unknown as CommandApi
|
||||
|
||||
const result = await loadCommands("/repo", api)
|
||||
|
||||
expect(calls).toEqual([{ location: { directory: "/repo" } }])
|
||||
expect(result).toEqual([{ name: "review", template: "Review files", source: "command" }])
|
||||
})
|
||||
|
||||
test("loads projects from the current endpoint", async () => {
|
||||
const api = {
|
||||
list: async () => [
|
||||
{ id: "b", worktree: "/b", time: { created: 1, updated: 1 }, sandboxes: [] },
|
||||
{ id: "a", worktree: "/a", time: { created: 1, updated: 1 }, sandboxes: [] },
|
||||
],
|
||||
} as unknown as ProjectApi
|
||||
|
||||
const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, api))
|
||||
|
||||
expect(result.map((project) => project.id)).toEqual(["a", "b"])
|
||||
})
|
||||
|
||||
test("loads references from the current location-scoped endpoint", async () => {
|
||||
const calls: unknown[] = []
|
||||
const api = {
|
||||
list: async (input: unknown) => {
|
||||
calls.push(input)
|
||||
return { location: {}, data: [{ name: "AGENTS.md", path: "/repo/AGENTS.md", source: "instructions" }] }
|
||||
},
|
||||
} as unknown as ReferenceApi
|
||||
|
||||
const result = await new QueryClient().fetchQuery(loadReferencesQuery(ServerScope.local, "/repo", api))
|
||||
|
||||
expect(calls).toEqual([{ location: { directory: "/repo" } }])
|
||||
expect(result).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,6 +9,26 @@ import type {
|
||||
ReferenceInfo,
|
||||
Session,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import type {
|
||||
AgentListInput,
|
||||
AgentListOutput,
|
||||
CatalogApi,
|
||||
CommandInfo,
|
||||
CommandListInput,
|
||||
CommandListOutput,
|
||||
McpApi,
|
||||
PathGetInput,
|
||||
PathGetOutput,
|
||||
PermissionApi,
|
||||
ProjectCurrentInput,
|
||||
ProjectCurrentOutput,
|
||||
ProjectListOutput,
|
||||
QuestionApi,
|
||||
ReferenceListInput,
|
||||
ReferenceListOutput,
|
||||
SessionApi,
|
||||
VcsApi,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { retry } from "@opencode-ai/core/util/retry"
|
||||
@@ -16,12 +36,20 @@ import { batch } from "solid-js"
|
||||
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import type { State, VcsCache } from "./types"
|
||||
import type { ServerSession } from "../server-session"
|
||||
import { cmp, normalizeAgentList, normalizeProviderList } from "./utils"
|
||||
import {
|
||||
cmp,
|
||||
normalizeAgentList,
|
||||
normalizePermissionRequest,
|
||||
normalizeProjectInfo,
|
||||
normalizeProviderList,
|
||||
} from "./utils"
|
||||
import { formatServerError } from "@/utils/server-errors"
|
||||
import { QueryClient, queryOptions } from "@tanstack/solid-query"
|
||||
import { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync"
|
||||
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
|
||||
import { normalizeSessionInfo } from "@/utils/session"
|
||||
import type { ServerProtocol } from "@/utils/server-protocol"
|
||||
|
||||
type GlobalStore = {
|
||||
ready: boolean
|
||||
@@ -88,15 +116,25 @@ export const loadGlobalConfigQuery = (scope: ServerScope, sdk: OpencodeClient) =
|
||||
queryFn: () => retry(() => sdk.global.config.get().then((x) => x.data!)),
|
||||
})
|
||||
|
||||
export const loadProjectsQuery = (scope: ServerScope, sdk: OpencodeClient) =>
|
||||
type ProjectApi = {
|
||||
readonly list: () => Promise<ProjectListOutput>
|
||||
readonly current: (input?: ProjectCurrentInput) => Promise<ProjectCurrentOutput>
|
||||
}
|
||||
|
||||
type PathApi = {
|
||||
readonly get: (input?: PathGetInput) => Promise<PathGetOutput>
|
||||
}
|
||||
|
||||
export const loadProjectsQuery = (scope: ServerScope, api: ProjectApi) =>
|
||||
queryOptions({
|
||||
queryKey: [scope, "project"],
|
||||
queryFn: () =>
|
||||
retry(() =>
|
||||
sdk.project.list().then((x) => {
|
||||
return (x.data ?? [])
|
||||
api.list().then((projects) => {
|
||||
return projects
|
||||
.filter((p) => !!p?.id)
|
||||
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
|
||||
.map(normalizeProjectInfo)
|
||||
.slice()
|
||||
.sort((a, b) => cmp(a.id, b.id))
|
||||
}),
|
||||
@@ -105,6 +143,8 @@ export const loadProjectsQuery = (scope: ServerScope, sdk: OpencodeClient) =>
|
||||
|
||||
export async function bootstrapGlobal(input: {
|
||||
serverSDK: OpencodeClient
|
||||
serverAPI: CatalogApi & { readonly path: PathApi; readonly project: ProjectApi }
|
||||
protocol?: Promise<ServerProtocol>
|
||||
scope: ServerScope
|
||||
requestFailedTitle: string
|
||||
translate: (key: string, vars?: Record<string, string | number>) => string
|
||||
@@ -114,11 +154,14 @@ export async function bootstrapGlobal(input: {
|
||||
}) {
|
||||
const slow = [
|
||||
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.serverSDK)),
|
||||
() => input.queryClient.fetchQuery(loadProvidersQuery(input.scope, null, input.serverSDK)),
|
||||
() => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverSDK)),
|
||||
() =>
|
||||
input.queryClient.fetchQuery(
|
||||
loadProvidersQuery(input.scope, null, input.serverAPI, input.serverSDK, input.protocol),
|
||||
),
|
||||
() => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverAPI.path)),
|
||||
() =>
|
||||
input.queryClient
|
||||
.fetchQuery(loadProjectsQuery(input.scope, input.serverSDK))
|
||||
.fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project))
|
||||
.then((data) => input.setGlobalStore("project", data)),
|
||||
]
|
||||
await runAll(slow)
|
||||
@@ -162,44 +205,117 @@ function warmSessions(input: {
|
||||
ids: string[]
|
||||
store: Store<State>
|
||||
setStore: SetStoreFunction<State>
|
||||
sdk: OpencodeClient
|
||||
api: SessionApi
|
||||
}) {
|
||||
const known = new Set(input.store.session.map((item) => item.id))
|
||||
const ids = [...new Set(input.ids)].filter((id) => !!id && !known.has(id))
|
||||
if (ids.length === 0) return Promise.resolve()
|
||||
return Promise.all(
|
||||
ids.map((sessionID) =>
|
||||
retry(() => input.sdk.session.get({ sessionID })).then((x) => {
|
||||
const session = x.data
|
||||
if (!session?.id) return
|
||||
mergeSession(input.setStore, session)
|
||||
}),
|
||||
retry(() => input.api.get({ sessionID })).then((session) =>
|
||||
mergeSession(input.setStore, normalizeSessionInfo(session)),
|
||||
),
|
||||
),
|
||||
).then(() => undefined)
|
||||
}
|
||||
|
||||
export const loadProvidersQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) =>
|
||||
export const loadProvidersQuery = (
|
||||
scope: ServerScope,
|
||||
directory: string | null,
|
||||
sdk: CatalogApi,
|
||||
legacy?: OpencodeClient,
|
||||
protocol?: Promise<ServerProtocol>,
|
||||
) =>
|
||||
queryOptions({
|
||||
queryKey: [scope, directory, "providers"],
|
||||
queryFn: () => retry(() => sdk.provider.list().then((x) => normalizeProviderList(x.data!))),
|
||||
queryFn: () =>
|
||||
retry(async () => {
|
||||
if ((await protocol) === "v1" && legacy) {
|
||||
const result = await legacy.provider.list()
|
||||
return normalizeProviderList(result.data!)
|
||||
}
|
||||
const location = directory ? { location: { directory } } : undefined
|
||||
const [providers, models, defaultModel] = await Promise.all([
|
||||
sdk.provider.list(location),
|
||||
sdk.model.list(location),
|
||||
sdk.model.default(location),
|
||||
])
|
||||
return normalizeProviderList(providers.data, models.data, defaultModel.data)
|
||||
}),
|
||||
})
|
||||
|
||||
export const loadAgentsQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) =>
|
||||
type AgentListApi = {
|
||||
readonly list: (input?: AgentListInput) => Promise<AgentListOutput>
|
||||
}
|
||||
|
||||
type CommandListApi = {
|
||||
readonly list: (input?: CommandListInput) => Promise<CommandListOutput>
|
||||
}
|
||||
|
||||
type ReferenceListApi = {
|
||||
readonly list: (input?: ReferenceListInput) => Promise<ReferenceListOutput>
|
||||
}
|
||||
|
||||
export const loadAgentsQuery = (
|
||||
scope: ServerScope,
|
||||
directory: string,
|
||||
sdk: AgentListApi,
|
||||
legacy?: OpencodeClient,
|
||||
protocol?: Promise<ServerProtocol>,
|
||||
) =>
|
||||
queryOptions({
|
||||
queryKey: [scope, directory, "agents"],
|
||||
queryFn: () => retry(() => sdk.app.agents().then((x) => normalizeAgentList(x.data))),
|
||||
queryFn: () =>
|
||||
retry(async () => {
|
||||
if ((await protocol) === "v1" && legacy) return normalizeAgentList((await legacy.app.agents()).data ?? [])
|
||||
return sdk.list({ location: { directory } }).then((result) => normalizeAgentList(result.data))
|
||||
}),
|
||||
})
|
||||
|
||||
export const loadPathQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) =>
|
||||
export const loadCommands = (
|
||||
directory: string,
|
||||
api: CommandListApi,
|
||||
legacy?: OpencodeClient,
|
||||
protocol?: Promise<ServerProtocol>,
|
||||
): Promise<CommandInfo[]> =>
|
||||
retry(async () => {
|
||||
if ((await protocol) === "v1" && legacy) {
|
||||
return ((await legacy.command.list()).data ?? []).map((command) => {
|
||||
const [providerID, id] = command.model?.split("/") ?? []
|
||||
return {
|
||||
name: command.name,
|
||||
template: command.template,
|
||||
description: command.description,
|
||||
agent: command.agent,
|
||||
model: providerID && id ? { providerID, id } : undefined,
|
||||
subtask: command.subtask,
|
||||
source: command.source === "skill" ? undefined : command.source,
|
||||
}
|
||||
})
|
||||
}
|
||||
return api.list({ location: { directory } }).then((result) => result.data)
|
||||
})
|
||||
|
||||
export const loadPathQuery = (scope: ServerScope, directory: string | null, api: PathApi) =>
|
||||
queryOptions<Path>({
|
||||
queryKey: [scope, directory, "path"],
|
||||
queryFn: () => retry(() => sdk.path.get().then((x) => x.data!)),
|
||||
queryFn: () => retry(() => api.get(directory ? { location: { directory } } : undefined)),
|
||||
})
|
||||
|
||||
export const loadReferencesQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
|
||||
export const loadReferencesQuery = (
|
||||
scope: ServerScope,
|
||||
directory: string,
|
||||
api: ReferenceListApi,
|
||||
legacy?: OpencodeClient,
|
||||
protocol?: Promise<ServerProtocol>,
|
||||
) =>
|
||||
queryOptions<ReferenceInfo[]>({
|
||||
queryKey: [scope, directory, "references"] as const,
|
||||
queryFn: () => retry(() => sdk.v2.reference.list().then((x) => x.data?.data ?? [])).catch(() => []),
|
||||
queryFn: () =>
|
||||
retry(async () => {
|
||||
if ((await protocol) === "v1" && legacy) return (await legacy.v2.reference.list()).data?.data ?? []
|
||||
return api.list({ location: { directory } }).then((result) => result.data)
|
||||
}).catch(() => []),
|
||||
placeholderData: [],
|
||||
})
|
||||
|
||||
@@ -208,6 +324,18 @@ export async function bootstrapDirectory(input: {
|
||||
scope: ServerScope
|
||||
mcp: boolean
|
||||
sdk: OpencodeClient
|
||||
api: CatalogApi & {
|
||||
readonly agent: AgentListApi
|
||||
readonly command: CommandListApi
|
||||
readonly mcp: McpApi
|
||||
readonly path: PathApi
|
||||
readonly permission: PermissionApi
|
||||
readonly project: ProjectApi
|
||||
readonly question: QuestionApi
|
||||
readonly reference: ReferenceListApi
|
||||
readonly session: SessionApi
|
||||
readonly vcs: VcsApi
|
||||
}
|
||||
store: Store<State>
|
||||
setStore: SetStoreFunction<State>
|
||||
vcsCache: VcsCache
|
||||
@@ -221,6 +349,7 @@ export async function bootstrapDirectory(input: {
|
||||
}
|
||||
queryClient: QueryClient
|
||||
session?: ServerSession
|
||||
protocol?: Promise<ServerProtocol>
|
||||
}) {
|
||||
const loading = input.store.status !== "complete"
|
||||
const seededProject = projectID(input.directory, input.global.project)
|
||||
@@ -240,13 +369,15 @@ export async function bootstrapDirectory(input: {
|
||||
() => Promise.resolve(input.loadSessions(input.directory)),
|
||||
() =>
|
||||
input.queryClient
|
||||
.ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.sdk))
|
||||
.ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.api.agent, input.sdk, input.protocol))
|
||||
.then((data) => input.setStore("agent", data)),
|
||||
() =>
|
||||
retry(() => input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))),
|
||||
() =>
|
||||
retry(() =>
|
||||
input.sdk.session.status().then(async (x) => {
|
||||
(async () => {
|
||||
if ((await input.protocol) !== "v1") return
|
||||
const x = await input.sdk.session.status()
|
||||
if (!input.session) {
|
||||
input.setStore("session_status", x.data!)
|
||||
return
|
||||
@@ -264,42 +395,56 @@ export async function bootstrapDirectory(input: {
|
||||
for (const [sessionID, status] of Object.entries(statuses)) {
|
||||
input.session.set("session_status", sessionID, reconcile(status))
|
||||
}
|
||||
// Warm session info only after seeding statuses so a stalled session
|
||||
// fetch cannot park busy indicators behind it, mirroring how live
|
||||
// session.status events apply first and resolve info in the background.
|
||||
await Promise.all(
|
||||
Object.keys(statuses).map((sessionID) => input.session!.resolve(sessionID).catch(() => undefined)),
|
||||
)
|
||||
}),
|
||||
})(),
|
||||
),
|
||||
!seededProject &&
|
||||
(() => retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id))),
|
||||
(() =>
|
||||
retry(() => input.api.project.current({ location: { directory: input.directory } })).then((project) =>
|
||||
input.setStore("project", project.id),
|
||||
)),
|
||||
!seededPath &&
|
||||
(() =>
|
||||
input.queryClient.ensureQueryData(loadPathQuery(input.scope, input.directory, input.sdk)).then((data) => {
|
||||
const next = projectID(data.directory ?? input.directory, input.global.project)
|
||||
if (next) input.setStore("project", next)
|
||||
})),
|
||||
input.queryClient
|
||||
.ensureQueryData(loadPathQuery(input.scope, input.directory, input.api.path))
|
||||
.then((data) => {
|
||||
const next = projectID(data.directory ?? input.directory, input.global.project)
|
||||
if (next) input.setStore("project", next)
|
||||
})),
|
||||
() =>
|
||||
retry(() =>
|
||||
input.sdk.vcs.get().then((x) => {
|
||||
const next = x.data ?? input.store.vcs
|
||||
input.api.vcs.get({ location: { directory: input.directory } }).then((result) => {
|
||||
const next = { branch: result.data.branch, default_branch: result.data.defaultBranch }
|
||||
input.setStore("vcs", next)
|
||||
if (next) input.vcsCache.setStore("value", next)
|
||||
}),
|
||||
),
|
||||
input.mcp && (() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])))),
|
||||
() => input.queryClient.fetchQuery(loadReferencesQuery(input.scope, input.directory, input.sdk)),
|
||||
input.mcp &&
|
||||
(() =>
|
||||
loadCommands(input.directory, input.api.command, input.sdk, input.protocol).then((commands) =>
|
||||
input.setStore("command", commands),
|
||||
)),
|
||||
() =>
|
||||
input.queryClient.fetchQuery(
|
||||
loadReferencesQuery(input.scope, input.directory, input.api.reference, input.sdk, input.protocol),
|
||||
),
|
||||
() =>
|
||||
retry(() =>
|
||||
input.sdk.permission.list().then((x) => {
|
||||
const ids = (x.data ?? []).map((perm) => perm?.sessionID).filter((id): id is string => !!id)
|
||||
(async () => {
|
||||
if ((await input.protocol) === "v1") return (await input.sdk.permission.list()).data ?? []
|
||||
return input.api.permission.request
|
||||
.list({ location: { directory: input.directory } })
|
||||
.then((result) => result.data.map(normalizePermissionRequest))
|
||||
})().then((permissions) => {
|
||||
const ids = permissions.map((permission) => permission.sessionID)
|
||||
const grouped = groupBySession(
|
||||
(x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID),
|
||||
permissions.filter((permission) => !!permission.id && !!permission.sessionID),
|
||||
)
|
||||
const warm = input.session
|
||||
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
|
||||
: warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk })
|
||||
: warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session })
|
||||
return warm.then(() =>
|
||||
batch(() => {
|
||||
const current = input.session?.data.permission ?? input.store.permission
|
||||
@@ -323,12 +468,19 @@ export async function bootstrapDirectory(input: {
|
||||
),
|
||||
() =>
|
||||
retry(() =>
|
||||
input.sdk.question.list().then((x) => {
|
||||
const ids = (x.data ?? []).map((question) => question?.sessionID).filter((id): id is string => !!id)
|
||||
const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID))
|
||||
(async () => {
|
||||
if ((await input.protocol) === "v1") return (await input.sdk.question.list()).data ?? []
|
||||
return input.api.question.request
|
||||
.list({ location: { directory: input.directory } })
|
||||
.then((result) => result.data)
|
||||
})().then((questions) => {
|
||||
const ids = questions.map((question) => question.sessionID)
|
||||
const grouped = groupBySession(
|
||||
questions.filter((question) => !!question.id && !!question.sessionID) as QuestionRequest[],
|
||||
)
|
||||
const warm = input.session
|
||||
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
|
||||
: warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk })
|
||||
: warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session })
|
||||
return warm.then(() =>
|
||||
batch(() => {
|
||||
const current = input.session?.data.question ?? input.store.question
|
||||
@@ -351,17 +503,20 @@ export async function bootstrapDirectory(input: {
|
||||
}),
|
||||
),
|
||||
() => Promise.resolve(input.loadSessions(input.directory)),
|
||||
input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, input.directory, input.sdk))),
|
||||
input.mcp && (() => input.queryClient.fetchQuery(loadMcpResourcesQuery(input.scope, input.directory, input.sdk))),
|
||||
input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, input.directory, input.api.mcp))),
|
||||
input.mcp &&
|
||||
(() => input.queryClient.fetchQuery(loadMcpResourcesQuery(input.scope, input.directory, input.api.mcp))),
|
||||
() =>
|
||||
input.queryClient.fetchQuery(loadProvidersQuery(input.scope, input.directory, input.sdk)).catch((err) => {
|
||||
const project = getFilename(input.directory)
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: input.translate("toast.project.reloadFailed.title", { project }),
|
||||
description: formatServerError(err, input.translate),
|
||||
})
|
||||
}),
|
||||
input.queryClient
|
||||
.fetchQuery(loadProvidersQuery(input.scope, input.directory, input.api, input.sdk, input.protocol))
|
||||
.catch((err) => {
|
||||
const project = getFilename(input.directory)
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: input.translate("toast.project.reloadFailed.title", { project }),
|
||||
description: formatServerError(err, input.translate),
|
||||
})
|
||||
}),
|
||||
].filter(Boolean) as (() => Promise<any>)[]
|
||||
|
||||
await waitForPaint()
|
||||
|
||||
@@ -255,6 +255,7 @@ export function createChildStoreManager(input: {
|
||||
vcs: vcsStore.value,
|
||||
limit: 5,
|
||||
message: {},
|
||||
session_message: {},
|
||||
part: {},
|
||||
part_text_accum_delta: {},
|
||||
})
|
||||
|
||||
@@ -80,6 +80,7 @@ const baseState = (input: Partial<State> = {}) =>
|
||||
vcs: undefined,
|
||||
limit: 10,
|
||||
message: {},
|
||||
session_message: {},
|
||||
part: {},
|
||||
part_text_accum_delta: {},
|
||||
...input,
|
||||
@@ -261,8 +262,8 @@ describe("applyDirectoryEvent", () => {
|
||||
|
||||
test("cleans session caches when deleted and decrements only root totals", () => {
|
||||
const cases = [
|
||||
{ info: rootSession({ id: "ses_1" }), expectedTotal: 1 },
|
||||
{ info: rootSession({ id: "ses_2", parentID: "ses_1" }), expectedTotal: 2 },
|
||||
{ info: rootSession({ id: "ses_1" }), expectedTotal: 1, current: false },
|
||||
{ info: rootSession({ id: "ses_2", parentID: "ses_1" }), expectedTotal: 2, current: true },
|
||||
]
|
||||
|
||||
for (const item of cases) {
|
||||
@@ -286,7 +287,10 @@ describe("applyDirectoryEvent", () => {
|
||||
)
|
||||
|
||||
applyDirectoryEvent({
|
||||
event: { type: "session.deleted", properties: { info: item.info } },
|
||||
event: {
|
||||
type: "session.deleted",
|
||||
properties: item.current ? { sessionID: item.info.id } : { info: item.info },
|
||||
},
|
||||
store,
|
||||
setStore,
|
||||
push() {},
|
||||
|
||||
@@ -8,9 +8,9 @@ import type {
|
||||
QuestionRequest,
|
||||
Session,
|
||||
SessionStatus,
|
||||
SnapshotFileDiff,
|
||||
Todo,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||
import type { State, VcsCache } from "./types"
|
||||
import { trimSessions } from "./session-trim"
|
||||
import { dropSessionCaches } from "./session-cache"
|
||||
@@ -171,8 +171,11 @@ export function applyDirectoryEvent(input: {
|
||||
break
|
||||
}
|
||||
case "session.deleted": {
|
||||
const info = (event.properties as { info: Session }).info
|
||||
const result = Binary.search(input.store.session, info.id, (s) => s.id)
|
||||
const properties = event.properties as { sessionID?: string; info?: Session }
|
||||
const sessionID = properties.info?.id ?? properties.sessionID
|
||||
if (!sessionID) break
|
||||
const result = Binary.search(input.store.session, sessionID, (s) => s.id)
|
||||
const info = properties.info ?? (result.found ? input.store.session[result.index] : undefined)
|
||||
if (result.found) {
|
||||
input.setStore(
|
||||
"session",
|
||||
@@ -181,14 +184,77 @@ export function applyDirectoryEvent(input: {
|
||||
}),
|
||||
)
|
||||
}
|
||||
cleanupSessionCaches(input.setStore, info.id, input.setSessionTodo)
|
||||
if (info.parentID) break
|
||||
cleanupSessionCaches(input.setStore, sessionID, input.setSessionTodo)
|
||||
if (info?.parentID) break
|
||||
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
|
||||
break
|
||||
}
|
||||
case "session.renamed": {
|
||||
const properties = event.properties as { sessionID: string; title: string }
|
||||
const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id)
|
||||
if (!result.found) break
|
||||
input.setStore("session", result.index, (session) => ({
|
||||
...session,
|
||||
title: properties.title,
|
||||
time: { ...session.time, updated: Date.now() },
|
||||
}))
|
||||
break
|
||||
}
|
||||
case "session.usage.updated": {
|
||||
const properties = event.properties as Pick<Session, "cost" | "tokens"> & { sessionID: string }
|
||||
const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id)
|
||||
if (!result.found) break
|
||||
input.setStore("session", result.index, (session) => ({
|
||||
...session,
|
||||
cost: properties.cost,
|
||||
tokens: properties.tokens,
|
||||
}))
|
||||
break
|
||||
}
|
||||
case "session.archived": {
|
||||
const properties = event.properties as { sessionID: string }
|
||||
const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id)
|
||||
if (!result.found) break
|
||||
const info = input.store.session[result.index]
|
||||
input.setStore(
|
||||
"session",
|
||||
produce((draft) => void draft.splice(result.index, 1)),
|
||||
)
|
||||
cleanupSessionCaches(input.setStore, properties.sessionID)
|
||||
if (!info?.parentID) input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
|
||||
break
|
||||
}
|
||||
case "session.moved": {
|
||||
const properties = event.properties as {
|
||||
sessionID: string
|
||||
location: { directory: string; workspaceID?: string }
|
||||
projectID?: string
|
||||
subpath?: string
|
||||
}
|
||||
const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id)
|
||||
if (!result.found) break
|
||||
if (properties.location.directory === input.directory) {
|
||||
input.setStore("session", result.index, (session) => ({
|
||||
...session,
|
||||
projectID: properties.projectID ?? session.projectID,
|
||||
workspaceID: properties.location.workspaceID,
|
||||
directory: properties.location.directory,
|
||||
path: properties.subpath,
|
||||
time: { ...session.time, updated: Date.now() },
|
||||
}))
|
||||
break
|
||||
}
|
||||
const info = input.store.session[result.index]
|
||||
input.setStore(
|
||||
"session",
|
||||
produce((draft) => void draft.splice(result.index, 1)),
|
||||
)
|
||||
if (!info?.parentID) input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
|
||||
break
|
||||
}
|
||||
case "session.diff": {
|
||||
const props = event.properties as { sessionID: string; diff: SnapshotFileDiff[] }
|
||||
input.setStore("session_diff", props.sessionID, reconcile(list(props.diff), { key: "file" }))
|
||||
const props = event.properties as { sessionID: string; diff: FileDiffInfo[] }
|
||||
input.setStore("session_diff", props.sessionID, reconcile(list(props.diff) as FileDiffInfo[], { key: "file" }))
|
||||
break
|
||||
}
|
||||
case "todo.updated": {
|
||||
|
||||
@@ -31,4 +31,24 @@ describe("toggleMcp", () => {
|
||||
await toggleMcp(input("disabled"))
|
||||
expect(calls).toEqual(["connect", "refresh"])
|
||||
})
|
||||
|
||||
test("does not toggle a server while its connection is pending", async () => {
|
||||
const calls: string[] = []
|
||||
await toggleMcp({
|
||||
status: "pending",
|
||||
connect: async () => {
|
||||
calls.push("connect")
|
||||
},
|
||||
disconnect: async () => {
|
||||
calls.push("disconnect")
|
||||
},
|
||||
authenticate: async () => {
|
||||
calls.push("authenticate")
|
||||
},
|
||||
refresh: async () => {
|
||||
calls.push("refresh")
|
||||
},
|
||||
})
|
||||
expect(calls).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import type { McpStatus } from "@opencode-ai/sdk/v2/client"
|
||||
import type { McpServer } from "@opencode-ai/client/promise"
|
||||
|
||||
export async function toggleMcp(input: {
|
||||
status: McpStatus["status"]
|
||||
status: McpServer["status"]["status"]
|
||||
connect: () => Promise<void>
|
||||
disconnect: () => Promise<void>
|
||||
authenticate: () => Promise<void>
|
||||
refresh: () => Promise<void>
|
||||
}) {
|
||||
if (input.status === "pending") return
|
||||
await {
|
||||
connected: input.disconnect,
|
||||
needs_auth: input.authenticate,
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type {
|
||||
Message,
|
||||
Part,
|
||||
PermissionRequest,
|
||||
QuestionRequest,
|
||||
SessionStatus,
|
||||
SnapshotFileDiff,
|
||||
Todo,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import type { Message, Part, PermissionRequest, QuestionRequest, SessionStatus, Todo } from "@opencode-ai/sdk/v2/client"
|
||||
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
|
||||
|
||||
const msg = (id: string, sessionID: string) =>
|
||||
@@ -33,9 +26,10 @@ describe("app session cache", () => {
|
||||
test("dropSessionCaches clears orphaned parts without message rows", () => {
|
||||
const store: {
|
||||
session_status: Record<string, SessionStatus | undefined>
|
||||
session_diff: Record<string, SnapshotFileDiff[] | undefined>
|
||||
session_diff: Record<string, FileDiffInfo[] | undefined>
|
||||
todo: Record<string, Todo[] | undefined>
|
||||
message: Record<string, Message[] | undefined>
|
||||
session_message: Record<string, never[] | undefined>
|
||||
part: Record<string, Part[] | undefined>
|
||||
permission: Record<string, PermissionRequest[] | undefined>
|
||||
question: Record<string, QuestionRequest[] | undefined>
|
||||
@@ -45,6 +39,7 @@ describe("app session cache", () => {
|
||||
session_diff: { ses_1: [] },
|
||||
todo: { ses_1: [] as Todo[] },
|
||||
message: {},
|
||||
session_message: {},
|
||||
part: { msg_1: [part("prt_1", "ses_1", "msg_1")] },
|
||||
permission: { ses_1: [] as PermissionRequest[] },
|
||||
question: { ses_1: [] as QuestionRequest[] },
|
||||
@@ -67,9 +62,10 @@ describe("app session cache", () => {
|
||||
const m = msg("msg_1", "ses_1")
|
||||
const store: {
|
||||
session_status: Record<string, SessionStatus | undefined>
|
||||
session_diff: Record<string, SnapshotFileDiff[] | undefined>
|
||||
session_diff: Record<string, FileDiffInfo[] | undefined>
|
||||
todo: Record<string, Todo[] | undefined>
|
||||
message: Record<string, Message[] | undefined>
|
||||
session_message: Record<string, never[] | undefined>
|
||||
part: Record<string, Part[] | undefined>
|
||||
permission: Record<string, PermissionRequest[] | undefined>
|
||||
question: Record<string, QuestionRequest[] | undefined>
|
||||
@@ -79,6 +75,7 @@ describe("app session cache", () => {
|
||||
session_diff: {},
|
||||
todo: {},
|
||||
message: { ses_1: [m] },
|
||||
session_message: {},
|
||||
part: { [m.id]: [part("prt_1", "ses_1", m.id)] },
|
||||
permission: {},
|
||||
question: {},
|
||||
|
||||
@@ -1,20 +1,15 @@
|
||||
import type {
|
||||
Message,
|
||||
Part,
|
||||
PermissionRequest,
|
||||
QuestionRequest,
|
||||
SessionStatus,
|
||||
SnapshotFileDiff,
|
||||
Todo,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import type { Message, Part, PermissionRequest, QuestionRequest, SessionStatus, Todo } from "@opencode-ai/sdk/v2/client"
|
||||
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
|
||||
export const SESSION_CACHE_LIMIT = 40
|
||||
|
||||
type SessionCache = {
|
||||
session_status: Record<string, SessionStatus | undefined>
|
||||
session_diff: Record<string, SnapshotFileDiff[] | undefined>
|
||||
session_diff: Record<string, FileDiffInfo[] | undefined>
|
||||
todo: Record<string, Todo[] | undefined>
|
||||
message: Record<string, Message[] | undefined>
|
||||
session_message: Record<string, SessionMessageInfo[] | undefined>
|
||||
part: Record<string, Part[] | undefined>
|
||||
permission: Record<string, PermissionRequest[] | undefined>
|
||||
question: Record<string, QuestionRequest[] | undefined>
|
||||
@@ -37,6 +32,7 @@ export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable<stri
|
||||
for (const sessionID of stale) {
|
||||
delete store.message[sessionID]
|
||||
delete store.todo[sessionID]
|
||||
delete store.session_message[sessionID]
|
||||
delete store.session_diff[sessionID]
|
||||
delete store.session_status[sessionID]
|
||||
delete store.permission[sessionID]
|
||||
|
||||
@@ -1,20 +1,28 @@
|
||||
import type { RootLoadArgs } from "./types"
|
||||
import type { SessionApi } from "@opencode-ai/client/promise"
|
||||
import { normalizeSessionInfo } from "@/utils/session"
|
||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
export async function loadRootSessionsWithFallback(input: RootLoadArgs) {
|
||||
export async function loadRootSessions(input: { api: Pick<SessionApi, "list">; directory: string; limit: number }) {
|
||||
const result = await input.api.list({
|
||||
directory: input.directory,
|
||||
parentID: null,
|
||||
limit: input.limit,
|
||||
order: "desc",
|
||||
})
|
||||
return {
|
||||
data: result.data.map(normalizeSessionInfo),
|
||||
limit: input.limit,
|
||||
limited: true,
|
||||
} as const
|
||||
}
|
||||
|
||||
export async function loadRootSessionsV1(input: { client: OpencodeClient; directory: string; limit: number }) {
|
||||
try {
|
||||
const result = await input.list({ directory: input.directory, roots: true, limit: input.limit })
|
||||
return {
|
||||
data: result.data,
|
||||
limit: input.limit,
|
||||
limited: true,
|
||||
} as const
|
||||
const result = await input.client.session.list({ directory: input.directory, roots: true, limit: input.limit })
|
||||
return { data: result.data, limit: input.limit, limited: true } as const
|
||||
} catch {
|
||||
const result = await input.list({ directory: input.directory, roots: true })
|
||||
return {
|
||||
data: result.data,
|
||||
limit: input.limit,
|
||||
limited: false,
|
||||
} as const
|
||||
const result = await input.client.session.list({ directory: input.directory, roots: true })
|
||||
return { data: result.data, limit: input.limit, limited: false } as const
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import type {
|
||||
Agent,
|
||||
Command,
|
||||
Config,
|
||||
LspStatus,
|
||||
McpResource,
|
||||
McpStatus,
|
||||
Message,
|
||||
Part,
|
||||
Path,
|
||||
@@ -13,11 +10,12 @@ import type {
|
||||
ReferenceInfo,
|
||||
Session,
|
||||
SessionStatus,
|
||||
SnapshotFileDiff,
|
||||
Todo,
|
||||
VcsInfo,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||
import type { CommandInfo, McpResource, McpServer, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import type { Accessor } from "solid-js"
|
||||
import type { SetStoreFunction, Store } from "solid-js/store"
|
||||
|
||||
@@ -35,7 +33,7 @@ export type ProjectMeta = {
|
||||
export type State = {
|
||||
status: "loading" | "partial" | "complete"
|
||||
agent: Agent[]
|
||||
command: Command[]
|
||||
command: CommandInfo[]
|
||||
reference: ReferenceInfo[]
|
||||
project: string
|
||||
projectMeta: ProjectMeta | undefined
|
||||
@@ -51,7 +49,7 @@ export type State = {
|
||||
}
|
||||
session_working(id: string): boolean
|
||||
session_diff: {
|
||||
[sessionID: string]: SnapshotFileDiff[]
|
||||
[sessionID: string]: FileDiffInfo[]
|
||||
}
|
||||
todo: {
|
||||
[sessionID: string]: Todo[]
|
||||
@@ -64,7 +62,7 @@ export type State = {
|
||||
}
|
||||
mcp_ready: boolean
|
||||
mcp: {
|
||||
[name: string]: McpStatus
|
||||
[name: string]: McpServer["status"]
|
||||
}
|
||||
mcp_resource: {
|
||||
[key: string]: McpResource
|
||||
@@ -76,6 +74,9 @@ export type State = {
|
||||
message: {
|
||||
[sessionID: string]: Message[]
|
||||
}
|
||||
session_message: {
|
||||
[sessionID: string]: SessionMessageInfo[]
|
||||
}
|
||||
part: {
|
||||
[messageID: string]: Part[]
|
||||
}
|
||||
@@ -128,18 +129,6 @@ export type DisposeCheck = {
|
||||
loadingSessions: boolean
|
||||
}
|
||||
|
||||
export type RootLoadArgs = {
|
||||
directory: string
|
||||
limit: number
|
||||
list: (query: { directory: string; roots: true; limit?: number }) => Promise<{ data?: Session[] }>
|
||||
}
|
||||
|
||||
export type RootLoadResult = {
|
||||
data?: Session[]
|
||||
limit: number
|
||||
limited: boolean
|
||||
}
|
||||
|
||||
export const MAX_DIR_STORES = 30
|
||||
export const DIR_IDLE_TTL_MS = 20 * 60 * 1000
|
||||
export const SESSION_RECENT_WINDOW = 4 * 60 * 60 * 1000
|
||||
|
||||
@@ -1,36 +1,117 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Agent } from "@opencode-ai/sdk/v2/client"
|
||||
import { directoryKey, normalizeAgentList } from "./utils"
|
||||
|
||||
const agent = (name = "build") =>
|
||||
({
|
||||
name,
|
||||
mode: "primary",
|
||||
permission: {},
|
||||
options: {},
|
||||
}) as Agent
|
||||
import type {
|
||||
AgentListOutput,
|
||||
ModelDefaultOutput,
|
||||
ModelListOutput,
|
||||
ProviderListOutput,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { directoryKey, normalizeAgentList, normalizePermissionRequest, normalizeProviderList } from "./utils"
|
||||
|
||||
describe("normalizeAgentList", () => {
|
||||
test("keeps array payloads", () => {
|
||||
expect(normalizeAgentList([agent("build"), agent("docs")])).toEqual([agent("build"), agent("docs")])
|
||||
})
|
||||
test("adapts current agents to the app agent shape", () => {
|
||||
const result = normalizeAgentList([
|
||||
{
|
||||
id: "build",
|
||||
name: "Build",
|
||||
mode: "primary",
|
||||
hidden: false,
|
||||
color: "primary",
|
||||
model: { id: "gpt-5", providerID: "openai", variant: "high" },
|
||||
request: { settings: { temperature: 0.2, topP: 0.9 }, headers: {}, body: {} },
|
||||
system: "Build software",
|
||||
permissions: [{ action: "read", resource: "*", effect: "allow" }],
|
||||
},
|
||||
] as AgentListOutput["data"])
|
||||
|
||||
test("wraps a single agent payload", () => {
|
||||
expect(normalizeAgentList(agent("docs"))).toEqual([agent("docs")])
|
||||
expect(result).toEqual([
|
||||
{
|
||||
name: "build",
|
||||
description: undefined,
|
||||
mode: "primary",
|
||||
hidden: false,
|
||||
temperature: 0.2,
|
||||
topP: 0.9,
|
||||
color: "primary",
|
||||
permission: [{ permission: "read", pattern: "*", action: "allow" }],
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
variant: "high",
|
||||
prompt: "Build software",
|
||||
options: { temperature: 0.2, topP: 0.9 },
|
||||
steps: undefined,
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
test("extracts agents from keyed objects", () => {
|
||||
describe("normalizePermissionRequest", () => {
|
||||
test("adapts the current permission request to app state", () => {
|
||||
expect(
|
||||
normalizeAgentList({
|
||||
build: agent("build"),
|
||||
docs: agent("docs"),
|
||||
normalizePermissionRequest({
|
||||
id: "permission-1",
|
||||
sessionID: "session-1",
|
||||
action: "read",
|
||||
resources: ["README.md"],
|
||||
save: ["*.md"],
|
||||
metadata: { path: "README.md" },
|
||||
source: { type: "tool", messageID: "message-1", callID: "call-1" },
|
||||
}),
|
||||
).toEqual([agent("build"), agent("docs")])
|
||||
).toEqual({
|
||||
id: "permission-1",
|
||||
sessionID: "session-1",
|
||||
permission: "read",
|
||||
patterns: ["README.md"],
|
||||
always: ["*.md"],
|
||||
metadata: { path: "README.md" },
|
||||
tool: { messageID: "message-1", callID: "call-1" },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test("drops invalid payloads", () => {
|
||||
expect(normalizeAgentList({ name: "AbortError" })).toEqual([])
|
||||
expect(normalizeAgentList([{ name: "build" }, agent("docs")])).toEqual([agent("docs")])
|
||||
describe("normalizeProviderList", () => {
|
||||
test("groups current models into the app provider catalog", () => {
|
||||
const result = normalizeProviderList(
|
||||
[{ id: "openai", name: "OpenAI", package: "@ai-sdk/openai" }] as ProviderListOutput["data"],
|
||||
[
|
||||
{
|
||||
id: "gpt-5",
|
||||
modelID: "gpt-5",
|
||||
providerID: "openai",
|
||||
name: "GPT-5",
|
||||
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
|
||||
variants: [{ id: "high" }],
|
||||
time: { released: 1 },
|
||||
cost: [{ input: 1, output: 2, cache: { read: 0.1, write: 0.2 } }],
|
||||
status: "active",
|
||||
enabled: true,
|
||||
limit: { context: 128_000, output: 8_192 },
|
||||
},
|
||||
{
|
||||
id: "gpt-old",
|
||||
modelID: "gpt-old",
|
||||
providerID: "openai",
|
||||
name: "GPT Old",
|
||||
capabilities: { tools: false, input: ["text"], output: ["text"] },
|
||||
variants: [],
|
||||
time: { released: 0 },
|
||||
cost: [],
|
||||
status: "deprecated",
|
||||
enabled: true,
|
||||
limit: { context: 1, output: 1 },
|
||||
},
|
||||
] as ModelListOutput["data"],
|
||||
{ id: "gpt-5", providerID: "openai" } as ModelDefaultOutput["data"],
|
||||
)
|
||||
|
||||
expect(result.connected).toEqual(["openai"])
|
||||
expect(result.default).toEqual({ openai: "gpt-5" })
|
||||
expect(result.all.get("openai")?.models["gpt-old"]).toBeUndefined()
|
||||
expect(result.all.get("openai")?.models["gpt-5"]).toMatchObject({
|
||||
id: "gpt-5",
|
||||
providerID: "openai",
|
||||
capabilities: { toolcall: true, attachment: true },
|
||||
cost: { input: 1, output: 2 },
|
||||
variants: { high: {} },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,39 +1,152 @@
|
||||
import type { Agent, Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client"
|
||||
import type {
|
||||
AgentListOutput,
|
||||
ModelDefaultOutput,
|
||||
ModelListOutput,
|
||||
PermissionV2Request,
|
||||
ProviderListOutput,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import type { Agent, PermissionRequest, Project, Provider, ProviderListResponse } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Project as CurrentProject } from "@opencode-ai/client/promise"
|
||||
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||
export { pathKey as directoryKey, type PathKey as DirectoryKey } from "@/utils/path-key"
|
||||
|
||||
export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||
|
||||
function isAgent(input: unknown): input is Agent {
|
||||
if (!input || typeof input !== "object") return false
|
||||
const item = input as { name?: unknown; mode?: unknown }
|
||||
if (typeof item.name !== "string") return false
|
||||
return item.mode === "subagent" || item.mode === "primary" || item.mode === "all"
|
||||
export function normalizeAgentList(input: AgentListOutput["data"] | Agent[]): Agent[] {
|
||||
if (input.every((agent) => !("request" in agent))) return input as Agent[]
|
||||
return (input as AgentListOutput["data"]).map((agent) => ({
|
||||
name: agent.id,
|
||||
description: agent.description,
|
||||
mode: agent.mode,
|
||||
hidden: agent.hidden,
|
||||
temperature:
|
||||
typeof agent.request.settings.temperature === "number" ? agent.request.settings.temperature : undefined,
|
||||
topP: typeof agent.request.settings.topP === "number" ? agent.request.settings.topP : undefined,
|
||||
color: agent.color,
|
||||
permission: agent.permissions.map((rule) => ({
|
||||
permission: rule.action,
|
||||
pattern: rule.resource,
|
||||
action: rule.effect,
|
||||
})),
|
||||
model: agent.model && { providerID: agent.model.providerID, modelID: agent.model.id },
|
||||
variant: agent.model?.variant,
|
||||
prompt: agent.system,
|
||||
options: agent.request.settings,
|
||||
steps: agent.steps,
|
||||
}))
|
||||
}
|
||||
|
||||
export function normalizeAgentList(input: unknown): Agent[] {
|
||||
if (Array.isArray(input)) return input.filter(isAgent)
|
||||
if (isAgent(input)) return [input]
|
||||
if (!input || typeof input !== "object") return []
|
||||
return Object.values(input).filter(isAgent)
|
||||
}
|
||||
|
||||
export function normalizeProviderList(input: ProviderListResponse): NormalizedProviderListResponse {
|
||||
export function normalizePermissionRequest(input: PermissionV2Request | PermissionRequest): PermissionRequest {
|
||||
if ("permission" in input) return input
|
||||
return {
|
||||
...input,
|
||||
all: new Map(
|
||||
input.all.map(
|
||||
(provider) =>
|
||||
[
|
||||
provider.id,
|
||||
{
|
||||
...provider,
|
||||
models: Object.fromEntries(
|
||||
Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated"),
|
||||
),
|
||||
},
|
||||
] as const,
|
||||
id: input.id,
|
||||
sessionID: input.sessionID,
|
||||
permission: input.action,
|
||||
patterns: input.resources,
|
||||
always: input.save ?? [],
|
||||
metadata: input.metadata ?? {},
|
||||
tool:
|
||||
input.source?.type === "tool" ? { messageID: input.source.messageID, callID: input.source.callID } : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeProviderList(
|
||||
providers: ProviderListOutput["data"] | ProviderListResponse,
|
||||
models?: ModelListOutput["data"],
|
||||
defaultModel?: ModelDefaultOutput["data"],
|
||||
): NormalizedProviderListResponse {
|
||||
if (!Array.isArray(providers)) {
|
||||
return {
|
||||
...providers,
|
||||
all: new Map(
|
||||
providers.all.map((provider) => [
|
||||
provider.id,
|
||||
{
|
||||
...provider,
|
||||
models: Object.fromEntries(
|
||||
Object.entries(provider.models).filter(([, model]) => model.status !== "deprecated"),
|
||||
),
|
||||
},
|
||||
]),
|
||||
),
|
||||
}
|
||||
}
|
||||
const all = new Map<string, Provider>()
|
||||
|
||||
for (const provider of providers) {
|
||||
all.set(provider.id, {
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
source: "custom",
|
||||
env: [],
|
||||
options: provider.settings ?? {},
|
||||
models: {},
|
||||
})
|
||||
}
|
||||
|
||||
for (const model of models ?? []) {
|
||||
const provider = all.get(model.providerID)
|
||||
if (!provider || model.status === "deprecated") continue
|
||||
const cost = model.cost.find((item) => item.tier === undefined) ?? model.cost[0]
|
||||
provider.models[model.id] = {
|
||||
id: model.id,
|
||||
providerID: model.providerID,
|
||||
api: {
|
||||
id: model.modelID,
|
||||
url: "",
|
||||
npm: model.package ?? provider.id,
|
||||
},
|
||||
name: model.name,
|
||||
family: model.family,
|
||||
capabilities: {
|
||||
temperature: false,
|
||||
reasoning: false,
|
||||
attachment: model.capabilities.input.some((item) => item !== "text"),
|
||||
toolcall: model.capabilities.tools,
|
||||
input: {
|
||||
text: model.capabilities.input.includes("text"),
|
||||
audio: model.capabilities.input.includes("audio"),
|
||||
image: model.capabilities.input.includes("image"),
|
||||
video: model.capabilities.input.includes("video"),
|
||||
pdf: model.capabilities.input.includes("pdf"),
|
||||
},
|
||||
output: {
|
||||
text: model.capabilities.output.includes("text"),
|
||||
audio: model.capabilities.output.includes("audio"),
|
||||
image: model.capabilities.output.includes("image"),
|
||||
video: model.capabilities.output.includes("video"),
|
||||
pdf: model.capabilities.output.includes("pdf"),
|
||||
},
|
||||
interleaved: false,
|
||||
},
|
||||
cost: {
|
||||
input: cost?.input ?? 0,
|
||||
output: cost?.output ?? 0,
|
||||
cache: {
|
||||
read: cost?.cache.read ?? 0,
|
||||
write: cost?.cache.write ?? 0,
|
||||
},
|
||||
},
|
||||
limit: model.limit,
|
||||
status: model.status,
|
||||
options: model.settings ?? {},
|
||||
headers: model.headers ?? {},
|
||||
release_date: new Date(model.time.released).toISOString().slice(0, 10),
|
||||
variants: Object.fromEntries(model.variants.map((variant) => [variant.id, variant.settings ?? {}])),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
all,
|
||||
connected: providers.map((provider) => provider.id),
|
||||
default: Object.fromEntries(
|
||||
providers.flatMap((provider) => {
|
||||
const model =
|
||||
defaultModel?.providerID === provider.id
|
||||
? defaultModel
|
||||
: models?.find((item) => item.providerID === provider.id && item.status !== "deprecated")
|
||||
return model ? [[provider.id, model.id]] : []
|
||||
}),
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -49,3 +162,10 @@ export function sanitizeProject(project: Project) {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeProjectInfo(project: Project | CurrentProject): Project {
|
||||
return {
|
||||
...project,
|
||||
vcs: project.vcs === "git" ? "git" : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useServerSDK } from "./server-sdk"
|
||||
import { RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServer } from "./server"
|
||||
import { usePlatform } from "./platform"
|
||||
import { Project } from "@opencode-ai/sdk/v2"
|
||||
import { normalizeProjectInfo } from "./global-sync/utils"
|
||||
import { Persist, persisted, removePersisted } from "@/utils/persist"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
@@ -570,7 +571,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
}
|
||||
|
||||
void serverSdk()
|
||||
.client.project.update({ projectID: project.id, directory: worktree, icon: { color } })
|
||||
.api.project.update({ projectID: project.id, icon: { color } })
|
||||
.then((result) =>
|
||||
serverSync().set("project", (items) =>
|
||||
items.map((item) => (item.id === result.id ? normalizeProjectInfo(result) : item)),
|
||||
),
|
||||
)
|
||||
.catch(() => {
|
||||
if (colorRequested.get(worktree) === color) colorRequested.delete(worktree)
|
||||
})
|
||||
|
||||
@@ -13,6 +13,7 @@ import { type DraftTab, useTabs } from "./tabs"
|
||||
import { useSettings } from "./settings"
|
||||
import { requireServerKey } from "@/utils/session-route"
|
||||
import type { ServerScope } from "@/utils/server-scope"
|
||||
import { normalizePermissionRequest } from "./global-sync/utils"
|
||||
import {
|
||||
acceptKey,
|
||||
directoryAcceptKey,
|
||||
@@ -243,9 +244,25 @@ function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }
|
||||
|
||||
const respond: PermissionRespondFn = (request) => {
|
||||
if (meta.disposed) return
|
||||
input.sdk.client.permission.respond(request).catch(() => {
|
||||
responded.delete(request.permissionID)
|
||||
})
|
||||
input.sdk.api.permission
|
||||
.reply({
|
||||
sessionID: request.sessionID,
|
||||
requestID: request.permissionID,
|
||||
reply: request.response,
|
||||
location: request.directory ? { directory: request.directory } : undefined,
|
||||
})
|
||||
.catch(() => {
|
||||
responded.delete(request.permissionID)
|
||||
})
|
||||
}
|
||||
|
||||
const list = async (directory: string) => {
|
||||
if ((await input.sdk.protocol) === "v1") {
|
||||
return (await input.sdk.client.permission.list({ directory })).data ?? []
|
||||
}
|
||||
return input.sdk.api.permission.request
|
||||
.list({ location: { directory } })
|
||||
.then((result) => result.data.map(normalizePermissionRequest))
|
||||
}
|
||||
|
||||
function respondOnce(permission: PermissionRequest, directory?: string) {
|
||||
@@ -343,14 +360,12 @@ function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }
|
||||
}),
|
||||
)
|
||||
|
||||
input.sdk.client.permission
|
||||
.list({ directory })
|
||||
.then((x) => {
|
||||
list(directory)
|
||||
.then((permissions) => {
|
||||
if (meta.disposed) return
|
||||
if (!isAutoAcceptingDirectory(directory)) return
|
||||
for (const perm of x.data ?? []) {
|
||||
if (!perm?.id) continue
|
||||
void respondPending(perm, directory, () => isAutoAcceptingDirectory(directory))
|
||||
for (const permission of permissions) {
|
||||
void respondPending(permission, directory, () => isAutoAcceptingDirectory(directory))
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
@@ -377,16 +392,14 @@ function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }
|
||||
}),
|
||||
)
|
||||
|
||||
input.sdk.client.permission
|
||||
.list({ directory })
|
||||
.then((x) => {
|
||||
list(directory)
|
||||
.then((permissions) => {
|
||||
if (meta.disposed) return
|
||||
if (enableVersion.get(key) !== version) return
|
||||
if (!isAutoAccepting(sessionID, directory)) return
|
||||
for (const perm of x.data ?? []) {
|
||||
if (!perm?.id) continue
|
||||
for (const permission of permissions) {
|
||||
void respondPending(
|
||||
perm,
|
||||
permission,
|
||||
directory,
|
||||
() => enableVersion.get(key) === version && isAutoAccepting(sessionID, directory),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { OpenCodeEvent, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import { createV2SessionReducer } from "./server-session-v2-reducer"
|
||||
|
||||
const event = (input: object) => input as OpenCodeEvent
|
||||
const base = { created: 1, location: { directory: "/repo" }, durable: { aggregateID: "ses_1", seq: 1, version: 1 } }
|
||||
|
||||
describe("v2 session reducer", () => {
|
||||
test("projects promoted input and streaming assistant content", () => {
|
||||
const reducer = createV2SessionReducer()
|
||||
let messages: SessionMessageInfo[] = []
|
||||
const apply = (input: object) => {
|
||||
const result = reducer.reduce(messages, event(input))
|
||||
if (result) messages = result.messages
|
||||
return result
|
||||
}
|
||||
|
||||
apply({
|
||||
...base,
|
||||
id: "evt_admitted",
|
||||
type: "session.input.admitted",
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
inputID: "msg_user",
|
||||
input: { type: "user", delivery: "steer", data: { text: "hello" } },
|
||||
},
|
||||
})
|
||||
apply({
|
||||
...base,
|
||||
id: "evt_promoted",
|
||||
type: "session.input.promoted",
|
||||
data: { sessionID: "ses_1", inputID: "msg_user" },
|
||||
})
|
||||
apply({
|
||||
...base,
|
||||
id: "evt_step",
|
||||
type: "session.step.started",
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
assistantMessageID: "msg_assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
},
|
||||
})
|
||||
apply({
|
||||
...base,
|
||||
id: "evt_text_start",
|
||||
type: "session.text.started",
|
||||
data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", ordinal: 0 },
|
||||
})
|
||||
apply({
|
||||
...base,
|
||||
id: "evt_text_delta",
|
||||
type: "session.text.delta",
|
||||
data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", ordinal: 0, delta: "hel" },
|
||||
})
|
||||
apply({
|
||||
...base,
|
||||
id: "evt_text_end",
|
||||
type: "session.text.ended",
|
||||
data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", ordinal: 0, text: "hello" },
|
||||
})
|
||||
|
||||
expect(messages[0]).toMatchObject({ id: "msg_user", type: "user", text: "hello" })
|
||||
expect(messages[1]).toMatchObject({
|
||||
id: "msg_assistant",
|
||||
type: "assistant",
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
})
|
||||
})
|
||||
|
||||
test("folds tool, retry, and completion events", () => {
|
||||
const reducer = createV2SessionReducer()
|
||||
let messages: SessionMessageInfo[] = []
|
||||
const apply = (input: object) => {
|
||||
const result = reducer.reduce(messages, event(input))
|
||||
if (result) messages = result.messages
|
||||
}
|
||||
|
||||
apply({
|
||||
...base,
|
||||
id: "evt_step",
|
||||
type: "session.step.started",
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
assistantMessageID: "msg_assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
},
|
||||
})
|
||||
apply({
|
||||
...base,
|
||||
id: "evt_tool_start",
|
||||
type: "session.tool.input.started",
|
||||
data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", callID: "call_1", name: "bash" },
|
||||
})
|
||||
apply({
|
||||
...base,
|
||||
id: "evt_tool_delta",
|
||||
type: "session.tool.input.delta",
|
||||
data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", callID: "call_1", delta: "{}" },
|
||||
})
|
||||
apply({
|
||||
...base,
|
||||
id: "evt_tool_called",
|
||||
type: "session.tool.called",
|
||||
data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", callID: "call_1", input: {}, executed: true },
|
||||
})
|
||||
apply({
|
||||
...base,
|
||||
id: "evt_tool_success",
|
||||
type: "session.tool.success",
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
assistantMessageID: "msg_assistant",
|
||||
callID: "call_1",
|
||||
structured: {},
|
||||
content: [{ type: "text", text: "done" }],
|
||||
executed: true,
|
||||
},
|
||||
})
|
||||
apply({
|
||||
...base,
|
||||
id: "evt_retry",
|
||||
type: "session.retry.scheduled",
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
assistantMessageID: "msg_assistant",
|
||||
attempt: 2,
|
||||
at: 10,
|
||||
error: { type: "ProviderError", message: "retry" },
|
||||
},
|
||||
})
|
||||
apply({ ...base, id: "evt_done", type: "session.execution.succeeded", data: { sessionID: "ses_1" } })
|
||||
|
||||
expect(messages[0]).toMatchObject({
|
||||
type: "assistant",
|
||||
retry: undefined,
|
||||
content: [{ type: "tool", id: "call_1", state: { status: "completed", content: [{ text: "done" }] } }],
|
||||
})
|
||||
})
|
||||
|
||||
test("requests hydration when promotion admission was missed", () => {
|
||||
const result = createV2SessionReducer().reduce(
|
||||
[],
|
||||
event({
|
||||
...base,
|
||||
id: "evt_promoted",
|
||||
type: "session.input.promoted",
|
||||
data: { sessionID: "ses_1", inputID: "msg_user" },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result).toMatchObject({ sessionID: "ses_1", missing: "msg_user", touched: [] })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,502 @@
|
||||
import type { OpenCodeEvent, SessionMessageInfo, SessionPendingMessage } from "@opencode-ai/client/promise"
|
||||
|
||||
type Assistant = Extract<SessionMessageInfo, { type: "assistant" }>
|
||||
type Compaction = Extract<SessionMessageInfo, { type: "compaction" }>
|
||||
type Shell = Extract<SessionMessageInfo, { type: "shell" }>
|
||||
|
||||
export type V2SessionReduction = {
|
||||
sessionID: string
|
||||
messages: SessionMessageInfo[]
|
||||
touched: string[]
|
||||
missing?: string
|
||||
}
|
||||
|
||||
export function createV2SessionReducer() {
|
||||
const pending = new Map<string, SessionPendingMessage>()
|
||||
|
||||
const reduce = (source: readonly SessionMessageInfo[], event: OpenCodeEvent): V2SessionReduction | undefined => {
|
||||
if (!("data" in event) || !("sessionID" in event.data) || typeof event.data.sessionID !== "string") return
|
||||
const sessionID = event.data.sessionID
|
||||
const result = (messages: SessionMessageInfo[], touched: string[] = []): V2SessionReduction => ({
|
||||
sessionID,
|
||||
messages,
|
||||
touched,
|
||||
})
|
||||
const append = (message: SessionMessageInfo) =>
|
||||
result(source.some((item) => item.id === message.id) ? [...source] : [...source, message], [message.id])
|
||||
|
||||
switch (event.type) {
|
||||
case "session.input.admitted":
|
||||
pending.set(key(sessionID, event.data.inputID), event.data.input)
|
||||
return result([...source])
|
||||
case "session.input.promoted": {
|
||||
const input = pending.get(key(sessionID, event.data.inputID))
|
||||
pending.delete(key(sessionID, event.data.inputID))
|
||||
if (!input) return { ...result([...source]), missing: event.data.inputID }
|
||||
if (input.type === "user")
|
||||
return append({
|
||||
id: event.data.inputID,
|
||||
type: "user",
|
||||
metadata: input.data.metadata,
|
||||
text: input.data.text,
|
||||
files: input.data.files,
|
||||
agents: input.data.agents,
|
||||
time: { created: event.created },
|
||||
})
|
||||
return append({
|
||||
id: event.data.inputID,
|
||||
type: "synthetic",
|
||||
metadata: input.data.metadata,
|
||||
text: input.data.text,
|
||||
description: input.data.description,
|
||||
time: { created: event.created },
|
||||
})
|
||||
}
|
||||
case "session.agent.selected":
|
||||
return append({
|
||||
id: messageID(event.id),
|
||||
type: "agent-switched",
|
||||
metadata: event.metadata,
|
||||
agent: event.data.agent,
|
||||
time: { created: event.created },
|
||||
})
|
||||
case "session.model.selected":
|
||||
return append({
|
||||
id: messageID(event.id),
|
||||
type: "model-switched",
|
||||
metadata: event.metadata,
|
||||
model: event.data.model,
|
||||
previous: source.findLast(
|
||||
(item): item is Extract<SessionMessageInfo, { type: "model-switched" | "assistant" }> =>
|
||||
item.type === "model-switched" || item.type === "assistant",
|
||||
)?.model,
|
||||
time: { created: event.created },
|
||||
})
|
||||
case "session.synthetic":
|
||||
return append({
|
||||
id: messageID(event.id),
|
||||
type: "synthetic",
|
||||
metadata: event.data.metadata,
|
||||
text: event.data.text,
|
||||
description: event.data.description,
|
||||
time: { created: event.created },
|
||||
})
|
||||
case "session.skill.activated":
|
||||
return append({
|
||||
id: messageID(event.id),
|
||||
type: "skill",
|
||||
metadata: event.metadata,
|
||||
skill: event.data.id,
|
||||
name: event.data.name,
|
||||
text: event.data.text,
|
||||
time: { created: event.created },
|
||||
})
|
||||
case "session.shell.started":
|
||||
return append({
|
||||
id: messageID(event.id),
|
||||
type: "shell",
|
||||
metadata: event.metadata,
|
||||
shellID: event.data.shell.id,
|
||||
command: event.data.shell.command,
|
||||
status: event.data.shell.status,
|
||||
exit: event.data.shell.exit,
|
||||
time: { created: event.created },
|
||||
})
|
||||
case "session.shell.ended":
|
||||
return updateMessage<Shell>(
|
||||
source,
|
||||
(item): item is Shell => item.type === "shell" && item.shellID === event.data.shell.id,
|
||||
(item) => ({
|
||||
...item,
|
||||
status: event.data.shell.status,
|
||||
exit: event.data.shell.exit,
|
||||
output: event.data.output,
|
||||
time: { ...item.time, completed: event.created },
|
||||
}),
|
||||
sessionID,
|
||||
)
|
||||
case "session.step.started": {
|
||||
const current = source.findLast((item): item is Assistant => item.type === "assistant" && !item.time.completed)
|
||||
const completed =
|
||||
current && current.id !== event.data.assistantMessageID
|
||||
? update(source, current.id, (item) =>
|
||||
item.type === "assistant"
|
||||
? { ...item, retry: undefined, time: { ...item.time, completed: event.created } }
|
||||
: item,
|
||||
)
|
||||
: [...source]
|
||||
const existing = completed.find((item) => item.id === event.data.assistantMessageID)
|
||||
if (existing?.type === "assistant")
|
||||
return result(
|
||||
update(completed, existing.id, (item) =>
|
||||
item.type === "assistant"
|
||||
? {
|
||||
...item,
|
||||
agent: event.data.agent,
|
||||
model: event.data.model,
|
||||
retry: undefined,
|
||||
error: undefined,
|
||||
finish: undefined,
|
||||
snapshot: event.data.snapshot ? { ...item.snapshot, start: event.data.snapshot } : item.snapshot,
|
||||
time: { ...item.time, completed: undefined },
|
||||
}
|
||||
: item,
|
||||
),
|
||||
current && current.id !== existing.id ? [current.id, existing.id] : [existing.id],
|
||||
)
|
||||
return result(
|
||||
[
|
||||
...completed,
|
||||
{
|
||||
id: event.data.assistantMessageID,
|
||||
type: "assistant",
|
||||
metadata: event.metadata,
|
||||
agent: event.data.agent,
|
||||
model: event.data.model,
|
||||
content: [],
|
||||
snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined,
|
||||
time: { created: event.created },
|
||||
},
|
||||
],
|
||||
current ? [current.id, event.data.assistantMessageID] : [event.data.assistantMessageID],
|
||||
)
|
||||
}
|
||||
case "session.step.ended":
|
||||
return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({
|
||||
...item,
|
||||
finish: event.data.finish,
|
||||
cost: event.data.cost,
|
||||
tokens: event.data.tokens,
|
||||
snapshot:
|
||||
event.data.snapshot || event.data.files
|
||||
? { ...item.snapshot, end: event.data.snapshot, files: event.data.files }
|
||||
: item.snapshot,
|
||||
time: { ...item.time, completed: event.created },
|
||||
}))
|
||||
case "session.step.failed":
|
||||
return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({
|
||||
...item,
|
||||
finish: "error",
|
||||
error: event.data.error,
|
||||
retry: undefined,
|
||||
cost: event.data.cost ?? item.cost,
|
||||
tokens: event.data.tokens ?? item.tokens,
|
||||
snapshot:
|
||||
event.data.snapshot || event.data.files
|
||||
? { ...item.snapshot, end: event.data.snapshot, files: event.data.files }
|
||||
: item.snapshot,
|
||||
time: { ...item.time, completed: event.created },
|
||||
}))
|
||||
case "session.text.started":
|
||||
return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({
|
||||
...item,
|
||||
content: insertOrdinal(item.content, "text", event.data.ordinal, { type: "text", text: "" }),
|
||||
}))
|
||||
case "session.text.delta":
|
||||
return updateContent(source, event.data.assistantMessageID, sessionID, "text", event.data.ordinal, (item) => ({
|
||||
...item,
|
||||
text: item.text + event.data.delta,
|
||||
}))
|
||||
case "session.text.ended":
|
||||
return updateContent(source, event.data.assistantMessageID, sessionID, "text", event.data.ordinal, (item) => ({
|
||||
...item,
|
||||
text: event.data.text,
|
||||
}))
|
||||
case "session.reasoning.started":
|
||||
return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({
|
||||
...item,
|
||||
content: insertOrdinal(item.content, "reasoning", event.data.ordinal, {
|
||||
type: "reasoning",
|
||||
text: "",
|
||||
state: event.data.state,
|
||||
time: { created: event.created },
|
||||
}),
|
||||
}))
|
||||
case "session.reasoning.delta":
|
||||
return updateContent(
|
||||
source,
|
||||
event.data.assistantMessageID,
|
||||
sessionID,
|
||||
"reasoning",
|
||||
event.data.ordinal,
|
||||
(item) => ({
|
||||
...item,
|
||||
text: item.text + event.data.delta,
|
||||
}),
|
||||
)
|
||||
case "session.reasoning.ended":
|
||||
return updateContent(
|
||||
source,
|
||||
event.data.assistantMessageID,
|
||||
sessionID,
|
||||
"reasoning",
|
||||
event.data.ordinal,
|
||||
(item) => ({
|
||||
...item,
|
||||
text: event.data.text,
|
||||
state: event.data.state ?? item.state,
|
||||
time: { created: item.time?.created ?? event.created, completed: event.created },
|
||||
}),
|
||||
)
|
||||
case "session.tool.input.started":
|
||||
return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({
|
||||
...item,
|
||||
content: item.content.some((content) => content.type === "tool" && content.id === event.data.callID)
|
||||
? item.content
|
||||
: [
|
||||
...item.content,
|
||||
{
|
||||
type: "tool",
|
||||
id: event.data.callID,
|
||||
name: event.data.name,
|
||||
state: { status: "streaming", input: "" },
|
||||
time: { created: event.created },
|
||||
},
|
||||
],
|
||||
}))
|
||||
case "session.tool.input.delta":
|
||||
return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) =>
|
||||
tool.state.status === "streaming"
|
||||
? { ...tool, state: { ...tool.state, input: tool.state.input + event.data.delta } }
|
||||
: tool,
|
||||
)
|
||||
case "session.tool.input.ended":
|
||||
return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) =>
|
||||
tool.state.status === "streaming" ? { ...tool, state: { ...tool.state, input: event.data.text } } : tool,
|
||||
)
|
||||
case "session.tool.called":
|
||||
return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) => ({
|
||||
...tool,
|
||||
executed: event.data.executed,
|
||||
providerState: event.data.state,
|
||||
state: { status: "running", input: event.data.input, structured: {}, content: [] },
|
||||
time: { ...tool.time, ran: event.created },
|
||||
}))
|
||||
case "session.tool.progress":
|
||||
return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) =>
|
||||
tool.state.status === "running"
|
||||
? { ...tool, state: { ...tool.state, structured: event.data.structured, content: event.data.content } }
|
||||
: tool,
|
||||
)
|
||||
case "session.tool.success":
|
||||
return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) => {
|
||||
if (tool.state.status !== "running") return tool
|
||||
return {
|
||||
...tool,
|
||||
executed: event.data.executed || tool.executed === true,
|
||||
providerResultState: event.data.resultState,
|
||||
state: {
|
||||
status: "completed",
|
||||
input: tool.state.input,
|
||||
structured: event.data.structured,
|
||||
content: event.data.content,
|
||||
result: event.data.result,
|
||||
},
|
||||
time: { ...tool.time, completed: event.created },
|
||||
}
|
||||
})
|
||||
case "session.tool.failed":
|
||||
return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) => {
|
||||
if (tool.state.status !== "streaming" && tool.state.status !== "running") return tool
|
||||
return {
|
||||
...tool,
|
||||
executed: event.data.executed || tool.executed === true,
|
||||
providerResultState: event.data.resultState,
|
||||
state: {
|
||||
status: "error",
|
||||
input: typeof tool.state.input === "string" ? {} : tool.state.input,
|
||||
structured: tool.state.status === "running" ? tool.state.structured : {},
|
||||
content: tool.state.status === "running" ? tool.state.content : [],
|
||||
error: event.data.error,
|
||||
result: event.data.result,
|
||||
},
|
||||
time: { ...tool.time, completed: event.created },
|
||||
}
|
||||
})
|
||||
case "session.retry.scheduled":
|
||||
return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({
|
||||
...item,
|
||||
retry: { attempt: event.data.attempt, at: event.data.at, error: event.data.error },
|
||||
}))
|
||||
case "session.execution.succeeded":
|
||||
case "session.execution.failed":
|
||||
case "session.execution.interrupted": {
|
||||
const current = source.findLast((item): item is Assistant => item.type === "assistant" && !item.time.completed)
|
||||
if (!current?.retry) return result([...source])
|
||||
return updateAssistant(source, current.id, sessionID, (item) => ({ ...item, retry: undefined }))
|
||||
}
|
||||
case "session.compaction.started":
|
||||
return append({
|
||||
id: event.data.inputID ?? messageID(event.id),
|
||||
type: "compaction",
|
||||
status: "running",
|
||||
metadata: event.metadata,
|
||||
reason: event.data.reason,
|
||||
summary: "",
|
||||
recent: event.data.recent,
|
||||
time: { created: event.created },
|
||||
})
|
||||
case "session.compaction.delta":
|
||||
return updateMessage<Extract<Compaction, { status: "running" }>>(
|
||||
source,
|
||||
(item): item is Extract<Compaction, { status: "running" }> =>
|
||||
item.type === "compaction" && item.status === "running",
|
||||
(item) => ({
|
||||
...item,
|
||||
summary: item.summary + event.data.text,
|
||||
}),
|
||||
sessionID,
|
||||
)
|
||||
case "session.compaction.ended": {
|
||||
const current = source.findLast(
|
||||
(item): item is Extract<Compaction, { status: "running" }> =>
|
||||
item.type === "compaction" && item.status === "running",
|
||||
)
|
||||
if (!current)
|
||||
return append({
|
||||
id: messageID(event.id),
|
||||
type: "compaction",
|
||||
status: "completed",
|
||||
metadata: event.metadata,
|
||||
reason: event.data.reason,
|
||||
summary: event.data.text,
|
||||
recent: event.data.recent,
|
||||
time: { created: event.created },
|
||||
})
|
||||
return result(
|
||||
update(source, current.id, () => ({
|
||||
...current,
|
||||
status: "completed",
|
||||
reason: event.data.reason,
|
||||
summary: event.data.text,
|
||||
recent: event.data.recent,
|
||||
})),
|
||||
[current.id],
|
||||
)
|
||||
}
|
||||
case "session.compaction.failed": {
|
||||
const current = source.findLast(
|
||||
(item): item is Extract<Compaction, { status: "running" }> =>
|
||||
item.type === "compaction" && item.status === "running",
|
||||
)
|
||||
const failed: Extract<Compaction, { status: "failed" }> = {
|
||||
id: current?.id ?? event.data.inputID ?? messageID(event.id),
|
||||
type: "compaction",
|
||||
status: "failed",
|
||||
metadata: current?.metadata ?? event.metadata,
|
||||
reason: event.data.reason,
|
||||
error: event.data.error,
|
||||
time: current?.time ?? { created: event.created },
|
||||
}
|
||||
if (!current) return append(failed)
|
||||
return result(
|
||||
update(source, current.id, () => failed),
|
||||
[failed.id],
|
||||
)
|
||||
}
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
reduce,
|
||||
clear(sessionID: string) {
|
||||
for (const id of pending.keys()) {
|
||||
if (id.startsWith(`${sessionID}:`)) pending.delete(id)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function key(sessionID: string, inputID: string) {
|
||||
return `${sessionID}:${inputID}`
|
||||
}
|
||||
|
||||
function messageID(eventID: string) {
|
||||
return eventID.replace(/^evt_/, "msg_")
|
||||
}
|
||||
|
||||
function update(
|
||||
source: readonly SessionMessageInfo[],
|
||||
id: string,
|
||||
apply: (item: SessionMessageInfo) => SessionMessageInfo,
|
||||
) {
|
||||
return source.map((item) => (item.id === id ? apply(item) : item))
|
||||
}
|
||||
|
||||
function updateMessage<T extends SessionMessageInfo>(
|
||||
source: readonly SessionMessageInfo[],
|
||||
matches: (item: SessionMessageInfo) => item is T,
|
||||
apply: (item: T) => T,
|
||||
sessionID: string,
|
||||
): V2SessionReduction {
|
||||
const current = source.findLast(matches)
|
||||
if (!current) return { sessionID, messages: [...source], touched: [] }
|
||||
return {
|
||||
sessionID,
|
||||
messages: update(source, current.id, (item) => (matches(item) ? apply(item) : item)),
|
||||
touched: [current.id],
|
||||
}
|
||||
}
|
||||
|
||||
function updateAssistant(
|
||||
source: readonly SessionMessageInfo[],
|
||||
id: string,
|
||||
sessionID: string,
|
||||
apply: (item: Assistant) => Assistant,
|
||||
): V2SessionReduction {
|
||||
return {
|
||||
sessionID,
|
||||
messages: update(source, id, (item) => (item.type === "assistant" ? apply(item) : item)),
|
||||
touched: source.some((item) => item.id === id && item.type === "assistant") ? [id] : [],
|
||||
}
|
||||
}
|
||||
|
||||
function updateContent<T extends "text" | "reasoning">(
|
||||
source: readonly SessionMessageInfo[],
|
||||
messageID: string,
|
||||
sessionID: string,
|
||||
type: T,
|
||||
ordinal: number,
|
||||
apply: (
|
||||
item: Extract<Assistant["content"][number], { type: T }>,
|
||||
) => Extract<Assistant["content"][number], { type: T }>,
|
||||
) {
|
||||
return updateAssistant(source, messageID, sessionID, (assistant) => {
|
||||
let index = -1
|
||||
return {
|
||||
...assistant,
|
||||
content: assistant.content.map((item) => {
|
||||
if (item.type !== type || ++index !== ordinal) return item
|
||||
return apply(item as Extract<Assistant["content"][number], { type: T }>)
|
||||
}),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function updateTool(
|
||||
source: readonly SessionMessageInfo[],
|
||||
messageID: string,
|
||||
callID: string,
|
||||
sessionID: string,
|
||||
apply: (
|
||||
item: Extract<Assistant["content"][number], { type: "tool" }>,
|
||||
) => Extract<Assistant["content"][number], { type: "tool" }>,
|
||||
) {
|
||||
return updateAssistant(source, messageID, sessionID, (assistant) => ({
|
||||
...assistant,
|
||||
content: assistant.content.map((item) => (item.type === "tool" && item.id === callID ? apply(item) : item)),
|
||||
}))
|
||||
}
|
||||
|
||||
function insertOrdinal<T extends Assistant["content"][number]["type"]>(
|
||||
source: Assistant["content"],
|
||||
type: T,
|
||||
ordinal: number,
|
||||
item: Extract<Assistant["content"][number], { type: T }>,
|
||||
) {
|
||||
const matches = source.filter((content) => content.type === type)
|
||||
if (matches[ordinal]) return source
|
||||
return [...source, item]
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { retry } from "@opencode-ai/core/util/retry"
|
||||
import type { MessageApi, OpenCodeEvent, SessionApi } from "@opencode-ai/client/promise"
|
||||
import type { Message, OpencodeClient, Part, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import { createServerSession } from "./server-session"
|
||||
|
||||
@@ -158,6 +159,57 @@ function setup(sessions: Record<string, Session>) {
|
||||
}
|
||||
|
||||
describe("server session", () => {
|
||||
test("projects V2 session events into current and legacy message state", () => {
|
||||
const ctx = setup({ child: session("child") })
|
||||
ctx.store.remember(session("child"))
|
||||
ctx.store.set("session_message", "child", [
|
||||
{
|
||||
id: "msg_1_user",
|
||||
type: "user",
|
||||
text: "hello",
|
||||
time: { created: 1 },
|
||||
},
|
||||
])
|
||||
const apply = (input: object) => ctx.store.applyV2(input as OpenCodeEvent)
|
||||
|
||||
apply({
|
||||
id: "evt_step",
|
||||
created: 2,
|
||||
type: "session.step.started",
|
||||
durable: { aggregateID: "child", seq: 1, version: 1 },
|
||||
location: { directory: "/repo" },
|
||||
data: {
|
||||
sessionID: "child",
|
||||
assistantMessageID: "msg_2_assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
},
|
||||
})
|
||||
apply({
|
||||
id: "evt_text_start",
|
||||
created: 3,
|
||||
type: "session.text.started",
|
||||
durable: { aggregateID: "child", seq: 2, version: 1 },
|
||||
location: { directory: "/repo" },
|
||||
data: { sessionID: "child", assistantMessageID: "msg_2_assistant", ordinal: 0 },
|
||||
})
|
||||
apply({
|
||||
id: "evt_text_delta",
|
||||
created: 4,
|
||||
type: "session.text.delta",
|
||||
location: { directory: "/repo" },
|
||||
data: { sessionID: "child", assistantMessageID: "msg_2_assistant", ordinal: 0, delta: "world" },
|
||||
})
|
||||
|
||||
expect(ctx.store.data.session_message.child?.at(-1)).toMatchObject({
|
||||
id: "msg_2_assistant",
|
||||
type: "assistant",
|
||||
content: [{ type: "text", text: "world" }],
|
||||
})
|
||||
expect(ctx.store.data.message.child?.map((message) => message.id)).toEqual(["msg_1_user", "msg_2_assistant"])
|
||||
expect(ctx.store.data.part.msg_2_assistant).toMatchObject([{ type: "text", text: "world" }])
|
||||
})
|
||||
|
||||
test("resolves lineage by session ID without directory", async () => {
|
||||
const ctx = setup({ child: session("child", "root"), root: session("root") })
|
||||
|
||||
@@ -178,6 +230,111 @@ describe("server session", () => {
|
||||
expect(ctx.store.data.message.root).toEqual([])
|
||||
})
|
||||
|
||||
test("loads current session content through the current message API", async () => {
|
||||
const requests: unknown[] = []
|
||||
const user = { id: "msg_z_user", type: "user", text: "hello", time: { created: 1 } }
|
||||
const assistant = {
|
||||
id: "msg_a_assistant",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: [{ type: "text", text: "hi" }],
|
||||
time: { created: 2, completed: 3 },
|
||||
}
|
||||
const client = {
|
||||
session: {
|
||||
messages: () => {
|
||||
throw new Error("legacy message endpoint called")
|
||||
},
|
||||
},
|
||||
} as unknown as OpencodeClient
|
||||
const messageApi = {
|
||||
list: async (input: unknown) => {
|
||||
requests.push(input)
|
||||
return { data: [assistant, user], cursor: { previous: null, next: null } }
|
||||
},
|
||||
} as unknown as MessageApi
|
||||
const store = createServerSession(client, {} as SessionApi, messageApi)
|
||||
store.remember(session("root"))
|
||||
|
||||
await store.sync("root")
|
||||
|
||||
expect(requests).toEqual([{ sessionID: "root", limit: 20, order: "desc" }])
|
||||
expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
|
||||
})
|
||||
|
||||
test("reprojects current assistants when an older page supplies their user", async () => {
|
||||
const user = { id: "msg_1_user", type: "user", text: "hello", time: { created: 1 } } as const
|
||||
const assistant = (id: string, created: number) => ({
|
||||
id,
|
||||
type: "assistant" as const,
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: [{ type: "text" as const, text: id }],
|
||||
time: { created, completed: created },
|
||||
})
|
||||
const assistants = [
|
||||
assistant("msg_2_assistant", 2),
|
||||
assistant("msg_3_assistant", 3),
|
||||
assistant("msg_4_assistant", 4),
|
||||
]
|
||||
const pages = [
|
||||
{ data: assistants.slice(1).toReversed(), cursor: { previous: null, next: "older" } },
|
||||
{ data: [assistants[0], user], cursor: { previous: null, next: null } },
|
||||
]
|
||||
const messageApi = {
|
||||
list: async () => pages.shift()!,
|
||||
} as unknown as MessageApi
|
||||
const store = createServerSession({} as OpencodeClient, {} as SessionApi, messageApi)
|
||||
store.remember(session("root"))
|
||||
|
||||
await store.sync("root")
|
||||
expect(store.data.message.root).toEqual([])
|
||||
|
||||
await store.history.loadMore("root")
|
||||
|
||||
expect(store.data.message.root.map((message) => message.id)).toEqual([
|
||||
user.id,
|
||||
...assistants.map((item) => item.id),
|
||||
])
|
||||
expect(assistants.map((item) => store.data.part[item.id]?.[0]?.type)).toEqual(["text", "text", "text"])
|
||||
})
|
||||
|
||||
test("indexes V1 messages for the current timeline projection", async () => {
|
||||
const user = userMessage("message-1", { sessionID: "root" })
|
||||
const assistant = assistantMessage("message-2", user.id, { sessionID: "root" })
|
||||
const client = messageClient(
|
||||
response([
|
||||
{ info: user, parts: [textPart(user.id, { sessionID: "root" })] },
|
||||
{ info: assistant, parts: [textPart(assistant.id, { sessionID: "root" })] },
|
||||
]),
|
||||
)
|
||||
const messageApi = {
|
||||
list: () => {
|
||||
throw new Error("current message endpoint called")
|
||||
},
|
||||
} as unknown as MessageApi
|
||||
const store = createServerSession(client, {} as SessionApi, messageApi, {
|
||||
protocol: Promise.resolve("v1"),
|
||||
})
|
||||
store.remember(session("root"))
|
||||
|
||||
await store.sync("root")
|
||||
|
||||
expect(store.data.message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
|
||||
expect(store.data.session_message.root).toMatchObject([
|
||||
{ id: user.id, type: "user", text: "text" },
|
||||
{ id: assistant.id, type: "assistant" },
|
||||
])
|
||||
|
||||
const next = userMessage("message-3", { sessionID: "root" })
|
||||
store.apply({ type: "message.updated", properties: { info: next } })
|
||||
expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id, next.id])
|
||||
|
||||
store.apply({ type: "message.removed", properties: { sessionID: "root", messageID: next.id } })
|
||||
expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
|
||||
})
|
||||
|
||||
test("backfills an assistant-only initial page through its user root", async () => {
|
||||
const user = userMessage("message-1")
|
||||
const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Binary } from "@opencode-ai/core/util/binary"
|
||||
import { retry } from "@opencode-ai/core/util/retry"
|
||||
import type { MessageApi, OpenCodeEvent, SessionApi, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import type {
|
||||
Message,
|
||||
OpencodeClient,
|
||||
@@ -8,15 +9,18 @@ import type {
|
||||
QuestionRequest,
|
||||
Session,
|
||||
SessionStatus,
|
||||
SnapshotFileDiff,
|
||||
Todo,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||
import { batch } from "solid-js"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { diffs as cleanDiffs, message as cleanMessage } from "@/utils/diffs"
|
||||
import { message as cleanMessage } from "@/utils/diffs"
|
||||
import { sessionNotFoundError } from "@/utils/server-errors"
|
||||
import { rootSession } from "@/utils/session-route"
|
||||
import { normalizeSessionInfo } from "@/utils/session"
|
||||
import { normalizeSessionMessages } from "@/utils/session-message"
|
||||
import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache"
|
||||
import { createV2SessionReducer, type V2SessionReduction } from "./server-session-v2-reducer"
|
||||
|
||||
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||
const cmpMessage = (a: Message, b: Message) => a.time.created - b.time.created || cmp(a.id, b.id)
|
||||
@@ -36,10 +40,37 @@ type OptimisticItem = {
|
||||
type MessagePage = {
|
||||
session: Message[]
|
||||
part: { id: string; part: Part[] }[]
|
||||
source?: SessionMessageInfo[]
|
||||
sourceMode?: "latest" | "older"
|
||||
projectSource?: boolean
|
||||
cursor?: string
|
||||
complete: boolean
|
||||
}
|
||||
|
||||
function legacyMessageSource(items: { info: Message; parts: Part[] }[]): SessionMessageInfo[] {
|
||||
return items
|
||||
.slice()
|
||||
.sort((a, b) => cmp(a.info.id, b.info.id))
|
||||
.map((item) => {
|
||||
if (item.info.role === "user") {
|
||||
return {
|
||||
id: item.info.id,
|
||||
type: "user" as const,
|
||||
text: item.parts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"),
|
||||
time: item.info.time,
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: item.info.id,
|
||||
type: "assistant" as const,
|
||||
agent: item.info.agent ?? item.info.mode,
|
||||
model: { id: item.info.modelID, providerID: item.info.providerID, variant: item.info.variant },
|
||||
content: [],
|
||||
time: item.info.time,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Most markers describe the current HTTP attempt; deltaParts persists non-durable stream state across retries.
|
||||
type MessageLoadState = {
|
||||
touchedMessages: Set<string>
|
||||
@@ -52,6 +83,7 @@ type MessageLoadState = {
|
||||
optimisticParts: Map<string, Set<string>>
|
||||
orphanParents: Set<string>
|
||||
clearedMessageParts: Set<string>
|
||||
touchedSource: Set<string>
|
||||
}
|
||||
|
||||
type MessageLoadBaseline = Pick<
|
||||
@@ -137,15 +169,25 @@ function reconcileFetched<T extends { id: string }>(
|
||||
return [...result.values()].sort((a, b) => cmp(a.id, b.id))
|
||||
}
|
||||
|
||||
export function createServerSession(client: OpencodeClient, options?: { retry?: typeof retry }) {
|
||||
type ServerSessionOptions = { retry?: typeof retry; protocol?: Promise<"v1" | "v2"> }
|
||||
|
||||
export function createServerSession(
|
||||
client: OpencodeClient,
|
||||
sessionApiOrOptions?: SessionApi | ServerSessionOptions,
|
||||
messageApi?: MessageApi,
|
||||
currentOptions?: ServerSessionOptions,
|
||||
) {
|
||||
const sessionApi = messageApi ? (sessionApiOrOptions as SessionApi) : undefined
|
||||
const options = messageApi ? currentOptions : (sessionApiOrOptions as ServerSessionOptions | undefined)
|
||||
const [data, setData] = createStore({
|
||||
info: {} as Record<string, Session | undefined>,
|
||||
session_status: {} as Record<string, SessionStatus>,
|
||||
session_diff: {} as Record<string, SnapshotFileDiff[]>,
|
||||
session_diff: {} as Record<string, FileDiffInfo[]>,
|
||||
todo: {} as Record<string, Todo[]>,
|
||||
permission: {} as Record<string, PermissionRequest[]>,
|
||||
question: {} as Record<string, QuestionRequest[]>,
|
||||
message: {} as Record<string, Message[]>,
|
||||
session_message: {} as Record<string, SessionMessageInfo[]>,
|
||||
part: {} as Record<string, Part[]>,
|
||||
part_text_accum_delta: {} as Record<string, string>,
|
||||
session_working(id: string) {
|
||||
@@ -154,9 +196,9 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
})
|
||||
const requests = new Map<string, Promise<Session>>()
|
||||
const inflight = new Map<string, Promise<void>>()
|
||||
const inflightDiff = new Map<string, Promise<void>>()
|
||||
const inflightTodo = new Map<string, Promise<void>>()
|
||||
const optimistic = new Map<string, Map<string, OptimisticItem>>()
|
||||
const v2 = createV2SessionReducer()
|
||||
const messageLoads = new Map<string, MessageLoadState>()
|
||||
const pendingParts = new Map<string, Map<string, Set<string>>>()
|
||||
const orphanParts = new Map<string, Set<string>>()
|
||||
@@ -191,6 +233,16 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
at: {} as Record<string, number | undefined>,
|
||||
})
|
||||
|
||||
const indexLegacyMessage = (message: Message) => {
|
||||
const current = data.session_message[message.sessionID] ?? []
|
||||
if (current.some((item) => item.id === message.id)) return
|
||||
setData(
|
||||
"session_message",
|
||||
message.sessionID,
|
||||
reconcile([...current, ...legacyMessageSource([{ info: message, parts: [] }])]),
|
||||
)
|
||||
}
|
||||
|
||||
const remember = (session: Session) => {
|
||||
setData("info", session.id, reconcile(session))
|
||||
infoSeen.delete(session.id)
|
||||
@@ -200,7 +252,6 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
...pinned.keys(),
|
||||
...requests.keys(),
|
||||
...inflight.keys(),
|
||||
...inflightDiff.keys(),
|
||||
...inflightTodo.keys(),
|
||||
...messageLoads.keys(),
|
||||
...optimistic.keys(),
|
||||
@@ -242,27 +293,31 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
const pending = requests.get(sessionID)
|
||||
if (pending) return pending
|
||||
const active = generation(sessionID)
|
||||
const request = client.session.get({ sessionID }).then((result) => {
|
||||
if (!result.data) throw sessionNotFoundError(sessionID)
|
||||
if (generations.get(sessionID) !== active) return result.data
|
||||
return remember(result.data)
|
||||
const request = sessionApi
|
||||
? sessionApi.get({ sessionID }).then(normalizeSessionInfo)
|
||||
: client.session.get({ sessionID }).then((result) => {
|
||||
if (!result.data) throw sessionNotFoundError(sessionID)
|
||||
return result.data
|
||||
})
|
||||
const resolved = request.then((result) => {
|
||||
if (generations.get(sessionID) !== active) return result
|
||||
return remember(result)
|
||||
})
|
||||
requests.set(sessionID, request)
|
||||
requests.set(sessionID, resolved)
|
||||
const cleanup = () => {
|
||||
if (requests.get(sessionID) === request) requests.delete(sessionID)
|
||||
if (requests.get(sessionID) === resolved) requests.delete(sessionID)
|
||||
if (
|
||||
generations.get(sessionID) === active &&
|
||||
!data.info[sessionID] &&
|
||||
!requests.has(sessionID) &&
|
||||
!messageLoads.has(sessionID) &&
|
||||
!inflight.has(sessionID) &&
|
||||
!inflightDiff.has(sessionID) &&
|
||||
!inflightTodo.has(sessionID)
|
||||
)
|
||||
generations.delete(sessionID)
|
||||
}
|
||||
void request.then(cleanup, cleanup)
|
||||
return request
|
||||
void resolved.then(cleanup, cleanup)
|
||||
return resolved
|
||||
}
|
||||
|
||||
const peekLineage = (sessionID: string) => {
|
||||
@@ -419,9 +474,9 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
clearOptimistic(sessionID)
|
||||
requests.delete(sessionID)
|
||||
inflight.delete(sessionID)
|
||||
inflightDiff.delete(sessionID)
|
||||
inflightTodo.delete(sessionID)
|
||||
messageLoads.delete(sessionID)
|
||||
v2.clear(sessionID)
|
||||
pendingParts.delete(sessionID)
|
||||
orphanParts.delete(sessionID)
|
||||
removedMessages.delete(sessionID)
|
||||
@@ -449,7 +504,6 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
...pinned.keys(),
|
||||
...requests.keys(),
|
||||
...inflight.keys(),
|
||||
...inflightDiff.keys(),
|
||||
...inflightTodo.keys(),
|
||||
...messageLoads.keys(),
|
||||
...optimistic.keys(),
|
||||
@@ -470,6 +524,25 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
)
|
||||
|
||||
const fetchMessages = async (sessionID: string, limit: number, before?: string, onAttempt?: () => void) => {
|
||||
if (messageApi && (await options?.protocol) !== "v1") {
|
||||
const response = await (options?.retry ?? retry)(() => {
|
||||
onAttempt?.()
|
||||
return messageApi.list(before ? { sessionID, limit, cursor: before } : { sessionID, limit, order: "desc" })
|
||||
})
|
||||
const source = [...response.data].reverse()
|
||||
const normalized = normalizeSessionMessages(sessionID, source)
|
||||
return {
|
||||
session: normalized.messages.sort((a, b) => cmp(a.id, b.id)),
|
||||
part: [...normalized.parts.entries()]
|
||||
.map(([id, part]) => ({ id, part: part.sort((a, b) => cmp(a.id, b.id)) }))
|
||||
.sort((a, b) => cmp(a.id, b.id)),
|
||||
source,
|
||||
sourceMode: before ? ("older" as const) : ("latest" as const),
|
||||
projectSource: true,
|
||||
cursor: response.cursor.next ?? undefined,
|
||||
complete: response.data.length === 0,
|
||||
}
|
||||
}
|
||||
const response = await (options?.retry ?? retry)(() => {
|
||||
onAttempt?.()
|
||||
return client.session.messages({ sessionID, limit, before })
|
||||
@@ -481,12 +554,24 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
id: item.info.id,
|
||||
part: item.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||
})),
|
||||
source: legacyMessageSource(items),
|
||||
sourceMode: before ? ("older" as const) : ("latest" as const),
|
||||
cursor: response.response.headers.get("x-next-cursor") ?? undefined,
|
||||
complete: !response.response.headers.get("x-next-cursor"),
|
||||
}
|
||||
}
|
||||
|
||||
const fetchMessage = async (sessionID: string, messageID: string, onAttempt?: () => void) => {
|
||||
if (sessionApi && (await options?.protocol) !== "v1") {
|
||||
const response = await (options?.retry ?? retry)(() => {
|
||||
onAttempt?.()
|
||||
return sessionApi.message({ sessionID, messageID })
|
||||
})
|
||||
const normalized = normalizeSessionMessages(sessionID, [response])
|
||||
const message = normalized.messages[0]
|
||||
if (!message) throw new Error(`Message not found: ${messageID}`)
|
||||
return { message, parts: normalized.parts.get(messageID) ?? [] }
|
||||
}
|
||||
const response = await (options?.retry ?? retry)(() => {
|
||||
onAttempt?.()
|
||||
return client.session.message({ sessionID, messageID })
|
||||
@@ -571,7 +656,31 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
preserveUnfetched: boolean | ((message: Message) => boolean),
|
||||
cleanupOrphans: boolean,
|
||||
) => {
|
||||
const merged = mergeOptimisticPage(page, [...(optimistic.get(sessionID)?.values() ?? [])])
|
||||
const source = page.source
|
||||
? (() => {
|
||||
const incoming = new Map(page.source.map((message) => [message.id, message]))
|
||||
const existing = data.session_message[sessionID] ?? []
|
||||
const current = existing.filter((message) => !incoming.has(message.id))
|
||||
const live = new Map(existing.map((message) => [message.id, message]))
|
||||
return (page.sourceMode === "older" ? [...page.source, ...current] : [...current, ...page.source]).map(
|
||||
(message) => (load?.touchedSource.has(message.id) ? (live.get(message.id) ?? message) : message),
|
||||
)
|
||||
})()
|
||||
: undefined
|
||||
const projected =
|
||||
page.projectSource && source
|
||||
? (() => {
|
||||
const normalized = normalizeSessionMessages(sessionID, source)
|
||||
return {
|
||||
...page,
|
||||
session: normalized.messages.sort((a, b) => cmp(a.id, b.id)),
|
||||
part: [...normalized.parts.entries()]
|
||||
.map(([id, part]) => ({ id, part: part.sort((a, b) => cmp(a.id, b.id)) }))
|
||||
.sort((a, b) => cmp(a.id, b.id)),
|
||||
}
|
||||
})()
|
||||
: page
|
||||
const merged = mergeOptimisticPage(projected, [...(optimistic.get(sessionID)?.values() ?? [])])
|
||||
merged.observed.forEach((item) => {
|
||||
if (!load?.clearedMessageParts.has(item.messageID)) confirmOptimistic(sessionID, item.messageID, item.parts)
|
||||
})
|
||||
@@ -583,6 +692,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
preserveUnfetched,
|
||||
})
|
||||
batch(() => {
|
||||
if (source) setData("session_message", sessionID, reconcile(source))
|
||||
const messageIDs = replaceMessages(sessionID, messages)
|
||||
replaceParts(sessionID, merged.part, messageIDs, load)
|
||||
const orphans = orphanParts.get(sessionID)
|
||||
@@ -613,6 +723,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
optimisticParts: new Map(),
|
||||
orphanParents: new Set(),
|
||||
clearedMessageParts: new Set(),
|
||||
touchedSource: new Set(),
|
||||
}
|
||||
messageLoads.set(sessionID, load)
|
||||
setMeta("loading", sessionID, true)
|
||||
@@ -747,6 +858,109 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
return properties.part.sessionID
|
||||
}
|
||||
|
||||
const projectV2 = (reduction: V2SessionReduction) => {
|
||||
reduction.touched.forEach((messageID) => messageLoads.get(reduction.sessionID)?.touchedSource.add(messageID))
|
||||
setData("session_message", reduction.sessionID, reconcile(reduction.messages))
|
||||
if (reduction.touched.length === 0) return
|
||||
|
||||
const touched = new Set(reduction.touched)
|
||||
let parentID: string | undefined
|
||||
for (const message of reduction.messages) {
|
||||
if (message.type === "user" || (message.type === "synthetic" && message.description?.trim()))
|
||||
parentID = message.id
|
||||
if (message.type === "shell") {
|
||||
if (touched.has(message.id)) touched.add(`${message.id}:assistant`)
|
||||
parentID = undefined
|
||||
}
|
||||
if (message.type === "assistant" && touched.has(message.id) && parentID) touched.add(parentID)
|
||||
if (message.type === "compaction" && touched.has(message.id) && parentID) touched.add(parentID)
|
||||
}
|
||||
|
||||
const normalized = normalizeSessionMessages(reduction.sessionID, reduction.messages)
|
||||
batch(() => {
|
||||
for (const message of normalized.messages) {
|
||||
if (!touched.has(message.id)) continue
|
||||
apply({ type: "message.updated", properties: { sessionID: reduction.sessionID, info: message } })
|
||||
}
|
||||
for (const messageID of touched) {
|
||||
const next = normalized.parts.get(messageID) ?? []
|
||||
const nextIDs = new Set(next.map((part) => part.id))
|
||||
for (const part of next) {
|
||||
apply({ type: "message.part.updated", properties: { sessionID: reduction.sessionID, part } })
|
||||
}
|
||||
for (const part of data.part[messageID] ?? []) {
|
||||
if (nextIDs.has(part.id)) continue
|
||||
apply({
|
||||
type: "message.part.removed",
|
||||
properties: { sessionID: reduction.sessionID, messageID, partID: part.id },
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const hydrateV2Message = (sessionID: string, messageID: string) => {
|
||||
if (!sessionApi) return
|
||||
void sessionApi
|
||||
.message({ sessionID, messageID })
|
||||
.then((message) => {
|
||||
const current = data.session_message[sessionID] ?? []
|
||||
const messages = [...current.filter((item) => item.id !== message.id), message].sort((a, b) => cmp(a.id, b.id))
|
||||
projectV2({ sessionID, messages, touched: [message.id] })
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
const applyV2 = (event: OpenCodeEvent) => {
|
||||
if (!("data" in event) || !("sessionID" in event.data) || typeof event.data.sessionID !== "string") return
|
||||
const sessionID = event.data.sessionID
|
||||
const reduction = v2.reduce(data.session_message[sessionID] ?? [], event)
|
||||
if (reduction) {
|
||||
projectV2(reduction)
|
||||
if (reduction.missing) hydrateV2Message(sessionID, reduction.missing)
|
||||
}
|
||||
|
||||
const info = data.info[sessionID]
|
||||
if (event.type === "session.renamed" && info)
|
||||
remember({ ...info, title: event.data.title, time: { ...info.time, updated: event.created } })
|
||||
if (event.type === "session.moved" && info)
|
||||
remember({
|
||||
...info,
|
||||
projectID: event.data.projectID ?? info.projectID,
|
||||
workspaceID: event.data.location.workspaceID,
|
||||
directory: event.data.location.directory,
|
||||
path: event.data.subpath,
|
||||
time: { ...info.time, updated: event.created },
|
||||
})
|
||||
if (event.type === "session.usage.updated" && info)
|
||||
remember({ ...info, cost: event.data.cost, tokens: event.data.tokens })
|
||||
if (event.type === "session.archived") {
|
||||
if (info) remember({ ...info, time: { ...info.time, archived: event.created, updated: event.created } })
|
||||
evict([sessionID])
|
||||
}
|
||||
if (event.type === "session.execution.started") setData("session_status", sessionID, { type: "busy" })
|
||||
if (
|
||||
event.type === "session.execution.succeeded" ||
|
||||
event.type === "session.execution.failed" ||
|
||||
event.type === "session.execution.interrupted"
|
||||
)
|
||||
setData("session_status", sessionID, { type: "idle" })
|
||||
if (event.type === "session.retry.scheduled")
|
||||
setData("session_status", sessionID, {
|
||||
type: "retry",
|
||||
attempt: event.data.attempt,
|
||||
message: event.data.error.message,
|
||||
next: event.data.at,
|
||||
})
|
||||
if (event.type === "session.forked") void resolve(sessionID, { force: true }).catch(() => {})
|
||||
if (
|
||||
event.type === "session.revert.staged" ||
|
||||
event.type === "session.revert.cleared" ||
|
||||
event.type === "session.revert.committed"
|
||||
)
|
||||
void resolve(sessionID, { force: true }).catch(() => {})
|
||||
}
|
||||
|
||||
const apply = (event: { type: string; properties?: unknown }) => {
|
||||
const eventID = eventSessionID(event)
|
||||
if (eventID) {
|
||||
@@ -770,7 +984,9 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
return
|
||||
}
|
||||
case "session.deleted": {
|
||||
const sessionID = (event.properties as { info: Session }).info.id
|
||||
const properties = event.properties as { sessionID?: string; info?: Session }
|
||||
const sessionID = properties.info?.id ?? properties.sessionID
|
||||
if (!sessionID) return
|
||||
infoSeen.delete(sessionID)
|
||||
setData(
|
||||
"info",
|
||||
@@ -779,11 +995,6 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
evict([sessionID])
|
||||
return
|
||||
}
|
||||
case "session.diff": {
|
||||
const props = event.properties as { sessionID: string; diff: SnapshotFileDiff[] }
|
||||
setData("session_diff", props.sessionID, reconcile(cleanDiffs(props.diff), { key: "file" }))
|
||||
return
|
||||
}
|
||||
case "todo.updated": {
|
||||
const props = event.properties as { sessionID: string; todos: Todo[] }
|
||||
setData("todo", props.sessionID, reconcile(props.todos, { key: "id" }))
|
||||
@@ -796,6 +1007,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
}
|
||||
case "message.updated": {
|
||||
const info = cleanMessage((event.properties as { info: Message }).info)
|
||||
indexLegacyMessage(info)
|
||||
const load = messageLoads.get(info.sessionID)
|
||||
load?.touchedMessages.add(info.id)
|
||||
load?.removedMessages.delete(info.id)
|
||||
@@ -828,6 +1040,9 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
}
|
||||
case "message.removed": {
|
||||
const props = event.properties as { sessionID: string; messageID: string }
|
||||
setData("session_message", props.sessionID, (messages) =>
|
||||
messages?.filter((message) => message.id !== props.messageID),
|
||||
)
|
||||
const load = messageLoads.get(props.sessionID)
|
||||
load?.touchedMessages.add(props.messageID)
|
||||
load?.removedMessages.add(props.messageID)
|
||||
@@ -1140,23 +1355,16 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
setData(produce((draft) => deleteMessageParts(draft, input.messageID)))
|
||||
},
|
||||
},
|
||||
diff(sessionID: string, options?: { force?: boolean }) {
|
||||
async todo(sessionID: string, request?: { force?: boolean }) {
|
||||
touch(sessionID)
|
||||
if (data.session_diff[sessionID] !== undefined && !options?.force) return Promise.resolve()
|
||||
return runInflight(inflightDiff, sessionID, () => {
|
||||
const active = generation(sessionID)
|
||||
return retry(() => client.session.diff({ sessionID })).then((result) => {
|
||||
if (generations.get(sessionID) !== active) return
|
||||
setData("session_diff", sessionID, reconcile(cleanDiffs(result.data), { key: "file" }))
|
||||
})
|
||||
})
|
||||
},
|
||||
todo(sessionID: string, options?: { force?: boolean }) {
|
||||
touch(sessionID)
|
||||
if (data.todo[sessionID] !== undefined && !options?.force) return Promise.resolve()
|
||||
if (data.todo[sessionID] !== undefined && !request?.force) return
|
||||
if ((await options?.protocol) === "v2") {
|
||||
setData("todo", sessionID, [])
|
||||
return
|
||||
}
|
||||
return runInflight(inflightTodo, sessionID, () => {
|
||||
const active = generation(sessionID)
|
||||
return retry(() => client.session.todo({ sessionID })).then((result) => {
|
||||
return (options?.retry ?? retry)(() => client.session.todo({ sessionID })).then((result) => {
|
||||
if (generations.get(sessionID) !== active) return
|
||||
setData("todo", sessionID, reconcile(result.data ?? [], { key: "id" }))
|
||||
})
|
||||
@@ -1190,6 +1398,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
if (count && count > 1) pinned.set(sessionID, count - 1)
|
||||
},
|
||||
apply,
|
||||
applyV2,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,104 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
import type {
|
||||
McpApi,
|
||||
McpListInput,
|
||||
McpResourceCatalogInput,
|
||||
SessionApi,
|
||||
SessionInfo,
|
||||
SessionListInput,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { QueryClient } from "@tanstack/solid-query"
|
||||
import { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction"
|
||||
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
|
||||
import { estimateRootSessionTotal, loadRootSessions } from "./global-sync/session-load"
|
||||
import { loadActiveSessionsQuery, loadMcpQuery, loadMcpResourcesQuery, seedActiveSessionStatuses } from "./server-sync"
|
||||
import { ServerScope } from "@/utils/server-scope"
|
||||
import { createServerSession } from "./server-session"
|
||||
|
||||
describe("MCP queries", () => {
|
||||
test("loads current servers for the requested location", async () => {
|
||||
const calls: unknown[] = []
|
||||
const queryClient = new QueryClient()
|
||||
const result = await queryClient.fetchQuery(
|
||||
loadMcpQuery(ServerScope.local, "/project", {
|
||||
list: async (input: McpListInput = {}) => {
|
||||
calls.push(input)
|
||||
return {
|
||||
location: { directory: "/project", project: { id: "project", directory: "/project" } },
|
||||
data: [
|
||||
{ name: "docs", status: { status: "connected" } },
|
||||
{ name: "search", status: { status: "pending" } },
|
||||
],
|
||||
}
|
||||
},
|
||||
} as unknown as McpApi),
|
||||
)
|
||||
|
||||
expect(calls).toEqual([{ location: { directory: "/project" } }])
|
||||
expect(result).toEqual({ docs: { status: "connected" }, search: { status: "pending" } })
|
||||
})
|
||||
|
||||
test("loads and keys the current resource catalog", async () => {
|
||||
const calls: unknown[] = []
|
||||
const queryClient = new QueryClient()
|
||||
const result = await queryClient.fetchQuery(
|
||||
loadMcpResourcesQuery(ServerScope.local, "/project", {
|
||||
resource: {
|
||||
catalog: async (input: McpResourceCatalogInput = {}) => {
|
||||
calls.push(input)
|
||||
return {
|
||||
location: { directory: "/project", project: { id: "project", directory: "/project" } },
|
||||
data: {
|
||||
resources: [{ server: "docs", name: "Guide", uri: "docs://guide" }],
|
||||
templates: [],
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
} as unknown as McpApi),
|
||||
)
|
||||
|
||||
expect(calls).toEqual([{ location: { directory: "/project" } }])
|
||||
expect(result).toEqual({ "docs:docs://guide": { server: "docs", name: "Guide", uri: "docs://guide" } })
|
||||
})
|
||||
})
|
||||
|
||||
describe("active session query", () => {
|
||||
test("loads active sessions immediately and once per server cache", async () => {
|
||||
let calls = 0
|
||||
const queryClient = new QueryClient()
|
||||
const options = loadActiveSessionsQuery(ServerScope.local, {
|
||||
active: async () => {
|
||||
calls++
|
||||
return { ses_running: { type: "running" } }
|
||||
},
|
||||
})
|
||||
|
||||
expect(await queryClient.fetchQuery(options)).toEqual({ ses_running: { type: "running" } })
|
||||
expect(await queryClient.fetchQuery(options)).toEqual({ ses_running: { type: "running" } })
|
||||
expect(calls).toBe(1)
|
||||
expect(options.enabled).toBe(true)
|
||||
expect([...options.queryKey]).toEqual([ServerScope.local, "activeSessions"])
|
||||
})
|
||||
|
||||
test("does not overwrite statuses already written by events", () => {
|
||||
const session = createServerSession({} as OpencodeClient)
|
||||
session.set("session_status", "ses_retry", { type: "retry", attempt: 2, message: "retrying", next: 10 })
|
||||
|
||||
seedActiveSessionStatuses(session, {
|
||||
ses_running: { type: "running" },
|
||||
ses_retry: { type: "running" },
|
||||
})
|
||||
|
||||
expect(session.data.session_status.ses_running).toEqual({ type: "busy" })
|
||||
expect(session.data.session_status.ses_retry).toEqual({
|
||||
type: "retry",
|
||||
attempt: 2,
|
||||
message: "retrying",
|
||||
next: 10,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("pickDirectoriesToEvict", () => {
|
||||
test("keeps pinned stores and evicts idle stores", () => {
|
||||
@@ -23,46 +121,57 @@ describe("pickDirectoriesToEvict", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("loadRootSessionsWithFallback", () => {
|
||||
test("uses limited roots query when supported", async () => {
|
||||
const calls: Array<{ directory: string; roots: true; limit?: number }> = []
|
||||
describe("loadRootSessions", () => {
|
||||
test("loads and normalizes a limited page of root sessions", async () => {
|
||||
const calls: SessionListInput[] = []
|
||||
|
||||
const result = await loadRootSessionsWithFallback({
|
||||
const result = await loadRootSessions({
|
||||
api: {
|
||||
list: async (query = {}) => {
|
||||
calls.push(query)
|
||||
return { data: [sessionInfo("session-1")], cursor: {} }
|
||||
},
|
||||
} satisfies Pick<SessionApi, "list">,
|
||||
directory: "dir",
|
||||
limit: 10,
|
||||
list: async (query) => {
|
||||
calls.push(query)
|
||||
return { data: [] }
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.data).toEqual([])
|
||||
expect(result.data).toEqual([
|
||||
expect.objectContaining({ id: "session-1", directory: "dir", slug: "session-1", version: "" }),
|
||||
])
|
||||
expect(result.limited).toBe(true)
|
||||
expect(calls).toEqual([{ directory: "dir", roots: true, limit: 10 }])
|
||||
expect(calls).toEqual([{ directory: "dir", parentID: null, limit: 10, order: "desc" }])
|
||||
})
|
||||
|
||||
test("falls back to full roots query on limited-query failure", async () => {
|
||||
const calls: Array<{ directory: string; roots: true; limit?: number }> = []
|
||||
|
||||
const result = await loadRootSessionsWithFallback({
|
||||
directory: "dir",
|
||||
limit: 25,
|
||||
list: async (query) => {
|
||||
calls.push(query)
|
||||
if (query.limit) throw new Error("unsupported")
|
||||
return { data: [] }
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.data).toEqual([])
|
||||
expect(result.limited).toBe(false)
|
||||
expect(calls).toEqual([
|
||||
{ directory: "dir", roots: true, limit: 25 },
|
||||
{ directory: "dir", roots: true },
|
||||
])
|
||||
test("propagates list failures", () => {
|
||||
expect(
|
||||
loadRootSessions({
|
||||
api: {
|
||||
list: async () => {
|
||||
throw new Error("failed")
|
||||
},
|
||||
} satisfies Pick<SessionApi, "list">,
|
||||
directory: "dir",
|
||||
limit: 25,
|
||||
}),
|
||||
).rejects.toThrow("failed")
|
||||
})
|
||||
})
|
||||
|
||||
function sessionInfo(id: string) {
|
||||
return {
|
||||
id,
|
||||
projectID: "project-1",
|
||||
agent: "build",
|
||||
model: { id: "model-1", providerID: "provider-1" },
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1, updated: 1 },
|
||||
title: id,
|
||||
location: { directory: "dir" },
|
||||
} as SessionInfo
|
||||
}
|
||||
|
||||
describe("estimateRootSessionTotal", () => {
|
||||
test("keeps exact total for full fetches", () => {
|
||||
expect(estimateRootSessionTotal({ count: 42, limit: 10, limited: false })).toBe(42)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type {
|
||||
Config,
|
||||
McpResource,
|
||||
OpencodeClient,
|
||||
Path,
|
||||
Project,
|
||||
ProviderAuthResponse,
|
||||
SessionStatus,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
bootstrapGlobal,
|
||||
clearProviderRev,
|
||||
loadAgentsQuery,
|
||||
loadCommands,
|
||||
loadGlobalConfigQuery,
|
||||
loadPathQuery,
|
||||
loadProjectsQuery,
|
||||
@@ -26,12 +27,13 @@ import {
|
||||
} from "./global-sync/bootstrap"
|
||||
import { createChildStoreManager } from "./global-sync/child-store"
|
||||
import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
|
||||
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
|
||||
import { estimateRootSessionTotal, loadRootSessions, loadRootSessionsV1 } from "./global-sync/session-load"
|
||||
import { trimSessions } from "./global-sync/session-trim"
|
||||
import type { ProjectMeta } from "./global-sync/types"
|
||||
import { SESSION_RECENT_LIMIT } from "./global-sync/types"
|
||||
import { formatServerError } from "@/utils/server-errors"
|
||||
import { queryOptions, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/solid-query"
|
||||
import type { SolidQueryOptions } from "@tanstack/solid-query"
|
||||
import { createRefreshQueue } from "./global-sync/queue"
|
||||
import { directoryKey } from "./global-sync/utils"
|
||||
import { PathKey } from "@/utils/path-key"
|
||||
@@ -45,8 +47,18 @@ import { retry } from "@opencode-ai/core/util/retry"
|
||||
import type { ServerScope } from "@/utils/server-scope"
|
||||
import { createHomeSessionIndexCache } from "./global-sync/home-session-index"
|
||||
import { persisted } from "@/utils/persist"
|
||||
import type { ServerApi } from "@/utils/server"
|
||||
import type {
|
||||
McpListInput,
|
||||
McpListOutput,
|
||||
McpResource,
|
||||
McpResourceCatalogInput,
|
||||
McpResourceCatalogOutput,
|
||||
McpServer,
|
||||
SessionActiveOutput,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { toggleMcp } from "./global-sync/mcp"
|
||||
import { createServerSession } from "./server-session"
|
||||
import { createServerSession, type ServerSession } from "./server-session"
|
||||
|
||||
type GlobalStore = {
|
||||
ready: boolean
|
||||
@@ -59,16 +71,76 @@ type GlobalStore = {
|
||||
reload: undefined | "pending" | "complete"
|
||||
}
|
||||
|
||||
export const loadMcpQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
|
||||
queryOptions({
|
||||
type McpListApi = {
|
||||
readonly list: (input?: McpListInput) => Promise<McpListOutput>
|
||||
}
|
||||
|
||||
type McpResourceApi = {
|
||||
readonly resource: {
|
||||
readonly catalog: (input?: McpResourceCatalogInput) => Promise<McpResourceCatalogOutput>
|
||||
}
|
||||
}
|
||||
|
||||
type ApiQueryOptions<T, K extends readonly unknown[]> = SolidQueryOptions<T, Error, T, K> & {
|
||||
initialData?: undefined
|
||||
queryKey: K
|
||||
}
|
||||
|
||||
type SessionActiveApi = {
|
||||
readonly active: () => Promise<SessionActiveOutput>
|
||||
}
|
||||
|
||||
export const loadMcpQuery = (
|
||||
scope: ServerScope,
|
||||
directory: string,
|
||||
api: McpListApi,
|
||||
legacy?: OpencodeClient,
|
||||
protocol?: Promise<"v1" | "v2">,
|
||||
): ApiQueryOptions<Record<string, McpServer["status"]>, readonly [ServerScope, string, "mcp"]> =>
|
||||
queryOptions<
|
||||
Record<string, McpServer["status"]>,
|
||||
Error,
|
||||
Record<string, McpServer["status"]>,
|
||||
readonly [ServerScope, string, "mcp"]
|
||||
>({
|
||||
queryKey: [scope, directory, "mcp"] as const,
|
||||
queryFn: () => sdk.mcp.status().then((r) => r.data ?? {}),
|
||||
queryFn: async () => {
|
||||
if ((await protocol) === "v1" && legacy) return (await legacy.mcp.status()).data ?? {}
|
||||
return api
|
||||
.list({ location: { directory } })
|
||||
.then((result) => Object.fromEntries(result.data.map((server) => [server.name, server.status])))
|
||||
},
|
||||
})
|
||||
|
||||
export const loadMcpResourcesQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
|
||||
queryOptions<Record<string, McpResource>>({
|
||||
export const loadMcpResourcesQuery = (
|
||||
scope: ServerScope,
|
||||
directory: string,
|
||||
api: McpResourceApi,
|
||||
legacy?: OpencodeClient,
|
||||
protocol?: Promise<"v1" | "v2">,
|
||||
): ApiQueryOptions<Record<string, McpResource>, readonly [ServerScope, string, "mcpResources"]> =>
|
||||
queryOptions<
|
||||
Record<string, McpResource>,
|
||||
Error,
|
||||
Record<string, McpResource>,
|
||||
readonly [ServerScope, string, "mcpResources"]
|
||||
>({
|
||||
queryKey: [scope, directory, "mcpResources"] as const,
|
||||
queryFn: () => sdk.experimental.resource.list().then((r) => r.data ?? {}),
|
||||
queryFn: async () => {
|
||||
if ((await protocol) === "v1" && legacy) {
|
||||
return Object.fromEntries(
|
||||
Object.entries((await legacy.experimental.resource.list()).data ?? {}).map(([key, resource]) => [
|
||||
key,
|
||||
{ ...resource, server: resource.client },
|
||||
]),
|
||||
)
|
||||
}
|
||||
return api.resource
|
||||
.catalog({ location: { directory } })
|
||||
.then((result) =>
|
||||
Object.fromEntries(result.data.resources.map((resource) => [`${resource.server}:${resource.uri}`, resource])),
|
||||
)
|
||||
},
|
||||
placeholderData: {},
|
||||
})
|
||||
|
||||
@@ -78,22 +150,51 @@ export const loadLspQuery = (scope: ServerScope, directory: string, sdk: Opencod
|
||||
queryFn: () => sdk.lsp.status().then((r) => r.data ?? []),
|
||||
})
|
||||
|
||||
export const loadActiveSessionsQuery = (
|
||||
scope: ServerScope,
|
||||
api: SessionActiveApi,
|
||||
): ApiQueryOptions<SessionActiveOutput, readonly [ServerScope, "activeSessions"]> =>
|
||||
queryOptions<SessionActiveOutput, Error, SessionActiveOutput, readonly [ServerScope, "activeSessions"]>({
|
||||
queryKey: [scope, "activeSessions"] as const,
|
||||
queryFn: () => api.active(),
|
||||
enabled: true,
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
gcTime: Number.POSITIVE_INFINITY,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
})
|
||||
|
||||
export function seedActiveSessionStatuses(
|
||||
session: Pick<ServerSession, "data" | "set">,
|
||||
active: SessionActiveOutput | Record<string, SessionStatus>,
|
||||
) {
|
||||
for (const sessionID of Object.keys(active)) {
|
||||
if (session.data.session_status[sessionID] !== undefined) continue
|
||||
const status = active[sessionID]
|
||||
session.set("session_status", sessionID, status?.type === "running" ? { type: "busy" } : status)
|
||||
}
|
||||
}
|
||||
|
||||
function makeQueryOptionsApi(
|
||||
scope: ServerScope,
|
||||
serverSDK: () => OpencodeClient,
|
||||
serverAPI: ServerApi,
|
||||
sdkFor: (dir: PathKey) => OpencodeClient,
|
||||
protocol: Promise<"v1" | "v2">,
|
||||
) {
|
||||
return {
|
||||
globalConfig: () => loadGlobalConfigQuery(scope, serverSDK()),
|
||||
projects: () => loadProjectsQuery(scope, serverSDK()),
|
||||
projects: () => loadProjectsQuery(scope, serverAPI.project),
|
||||
providers: (directory: PathKey | null) =>
|
||||
loadProvidersQuery(scope, directory, directory === null ? serverSDK() : sdkFor(directory)),
|
||||
path: (directory: PathKey | null) =>
|
||||
loadPathQuery(scope, directory, directory === null ? serverSDK() : sdkFor(directory)),
|
||||
agents: (directory: PathKey) => loadAgentsQuery(scope, directory, sdkFor(directory)),
|
||||
references: (directory: PathKey) => loadReferencesQuery(scope, directory, sdkFor(directory)),
|
||||
mcp: (directory: PathKey) => loadMcpQuery(scope, directory, sdkFor(directory)),
|
||||
mcpResources: (directory: PathKey) => loadMcpResourcesQuery(scope, directory, sdkFor(directory)),
|
||||
loadProvidersQuery(scope, directory, serverAPI, directory ? sdkFor(directory) : serverSDK(), protocol),
|
||||
path: (directory: PathKey | null) => loadPathQuery(scope, directory, serverAPI.path),
|
||||
agents: (directory: PathKey) => loadAgentsQuery(scope, directory, serverAPI.agent, sdkFor(directory), protocol),
|
||||
references: (directory: PathKey) =>
|
||||
loadReferencesQuery(scope, directory, serverAPI.reference, sdkFor(directory), protocol),
|
||||
mcp: (directory: PathKey) => loadMcpQuery(scope, directory, serverAPI.mcp, sdkFor(directory), protocol),
|
||||
mcpResources: (directory: PathKey) =>
|
||||
loadMcpResourcesQuery(scope, directory, serverAPI.mcp, sdkFor(directory), protocol),
|
||||
lsp: (directory: PathKey) => loadLspQuery(scope, directory, sdkFor(directory)),
|
||||
sessions: (directory: PathKey) => ({ queryKey: [scope, directory, "loadSessions"] as const }),
|
||||
}
|
||||
@@ -122,11 +223,44 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
return sdk
|
||||
}
|
||||
|
||||
const queryOptionsApi = makeQueryOptionsApi(serverSDK.scope, () => serverSDK.client, sdkFor)
|
||||
const session = createServerSession(serverSDK.client, serverSDK.api.session, serverSDK.api.message, {
|
||||
protocol: serverSDK.protocol,
|
||||
})
|
||||
const queryOptionsApi = makeQueryOptionsApi(
|
||||
serverSDK.scope,
|
||||
() => serverSDK.client,
|
||||
serverSDK.api,
|
||||
sdkFor,
|
||||
serverSDK.protocol,
|
||||
)
|
||||
|
||||
const [configQuery, providerQuery, pathQuery] = useQueries(() => ({
|
||||
queries: [queryOptionsApi.globalConfig(), queryOptionsApi.providers(null), queryOptionsApi.path(null)],
|
||||
}))
|
||||
const activeSessionsQuery = useQuery(() =>
|
||||
loadActiveSessionsQuery(serverSDK.scope, {
|
||||
active: async () => {
|
||||
if ((await serverSDK.protocol) === "v1") {
|
||||
const statuses = (await serverSDK.client.session.status()).data ?? {}
|
||||
seedActiveSessionStatuses(session, statuses)
|
||||
for (const sessionID of Object.keys(statuses)) {
|
||||
void session.resolve(sessionID).catch(() => undefined)
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(statuses).flatMap(([sessionID, status]) =>
|
||||
status.type === "idle" ? [] : [[sessionID, { type: "running" as const }]],
|
||||
),
|
||||
)
|
||||
}
|
||||
const active = await serverSDK.api.session.active()
|
||||
seedActiveSessionStatuses(session, active)
|
||||
for (const sessionID of Object.keys(active)) {
|
||||
void session.resolve(sessionID).catch(() => undefined)
|
||||
}
|
||||
return active
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const [globalStore, setGlobalStore] = createStore<GlobalStore>({
|
||||
get ready() {
|
||||
@@ -183,6 +317,8 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
queryFn: async () => {
|
||||
await bootstrapGlobal({
|
||||
serverSDK: serverSDK.client,
|
||||
serverAPI: serverSDK.api,
|
||||
protocol: serverSDK.protocol,
|
||||
scope: serverSDK.scope,
|
||||
requestFailedTitle: language.t("common.requestFailed"),
|
||||
translate: language.t,
|
||||
@@ -212,8 +348,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
bootstrapInstance,
|
||||
})
|
||||
|
||||
const session = createServerSession(serverSDK.client)
|
||||
|
||||
const children = createChildStoreManager({
|
||||
owner,
|
||||
scope: serverSDK.scope,
|
||||
@@ -224,17 +358,15 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
void bootstrapInstance(directory)
|
||||
},
|
||||
onMcp: (directory, setStore) => {
|
||||
void retry(() =>
|
||||
sdkFor(directory)
|
||||
.command.list()
|
||||
.then((x) => setStore("command", x.data ?? [])),
|
||||
).catch((err) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("toast.project.reloadFailed.title", { project: getFilename(directory) }),
|
||||
description: formatServerError(err, language.t),
|
||||
void loadCommands(directory, serverSDK.api.command, sdkFor(directory), serverSDK.protocol)
|
||||
.then((commands) => setStore("command", commands))
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("toast.project.reloadFailed.title", { project: getFilename(directory) }),
|
||||
description: formatServerError(err, language.t),
|
||||
})
|
||||
})
|
||||
})
|
||||
},
|
||||
onDispose: (directory) => {
|
||||
const key = directoryKey(directory)
|
||||
@@ -279,11 +411,12 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
.fetchQuery({
|
||||
...queryOptionsApi.sessions(key),
|
||||
queryFn: () =>
|
||||
loadRootSessionsWithFallback({
|
||||
directory,
|
||||
limit,
|
||||
list: (query) => serverSDK.client.session.list(query),
|
||||
})
|
||||
serverSDK.protocol
|
||||
.then((protocol) =>
|
||||
protocol === "v1"
|
||||
? loadRootSessionsV1({ client: sdkFor(directory), directory, limit })
|
||||
: loadRootSessions({ api: serverSDK.api.session, directory, limit }),
|
||||
)
|
||||
.then((x) => {
|
||||
const nonArchived = (x.data ?? [])
|
||||
.filter((s) => !!s?.id)
|
||||
@@ -353,6 +486,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
provider: globalStore.provider,
|
||||
},
|
||||
sdk,
|
||||
api: serverSDK.api,
|
||||
store: child[0],
|
||||
setStore: child[1],
|
||||
vcsCache: cache,
|
||||
@@ -360,6 +494,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
translate: language.t,
|
||||
queryClient,
|
||||
session,
|
||||
protocol: serverSDK.protocol,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -371,12 +506,31 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
return promise
|
||||
}
|
||||
|
||||
const indexSession = (info: Parameters<typeof session.remember>[0]) => {
|
||||
const key = directoryKey(info.directory)
|
||||
const existing = children.children[key]
|
||||
if (!existing) return
|
||||
applyDirectoryEvent({
|
||||
event: { type: "session.created", properties: { info } },
|
||||
directory: key,
|
||||
store: existing[0],
|
||||
setStore: existing[1],
|
||||
push: queue.push,
|
||||
retainedLimit: sessionMeta.get(key)?.limit,
|
||||
sessionContent: false,
|
||||
permission: session.data.permission,
|
||||
loadLsp() {},
|
||||
})
|
||||
}
|
||||
|
||||
const unsub = serverSDK.event.listen((e) => {
|
||||
const directory = e.name
|
||||
const key = directoryKey(directory)
|
||||
const event = e.details
|
||||
const eventType: string = event.type
|
||||
const recent = bootingRoot || Date.now() - bootedAt < 1500
|
||||
|
||||
if (event.current) session.applyV2(event.current)
|
||||
session.apply(event)
|
||||
if (event.type === "session.created" || event.type === "session.updated" || event.type === "session.deleted") {
|
||||
homeSessions.apply(event)
|
||||
@@ -384,6 +538,8 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
homeSessions.refresh(event.type)
|
||||
|
||||
if (directory === "global") {
|
||||
if (eventType === "server.connected" && activeSessionsQuery.data === undefined && !activeSessionsQuery.isFetching)
|
||||
void activeSessionsQuery.refetch()
|
||||
applyGlobalEvent({
|
||||
event,
|
||||
project: globalStore.project,
|
||||
@@ -393,7 +549,14 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
},
|
||||
setGlobalProject: setProjects,
|
||||
})
|
||||
if (event.type === "server.connected" || event.type === "global.disposed") {
|
||||
if (
|
||||
eventType === "config.updated" ||
|
||||
eventType === "catalog.updated" ||
|
||||
eventType === "agent.updated" ||
|
||||
eventType === "project.directories.updated"
|
||||
)
|
||||
bootstrap.refetch()
|
||||
if (eventType === "server.connected" || eventType === "global.disposed") {
|
||||
if (recent) return
|
||||
for (const directory of Object.keys(children.children)) {
|
||||
if (!children.active(directory)) continue
|
||||
@@ -403,9 +566,30 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.current?.type === "session.moved") {
|
||||
const info = session.get(event.current.data.sessionID)
|
||||
if (info) indexSession(info)
|
||||
}
|
||||
if (event.current?.type === "session.forked")
|
||||
void session
|
||||
.resolve(event.current.data.sessionID, { force: true })
|
||||
.then(indexSession)
|
||||
.catch(() => {})
|
||||
|
||||
const existing = children.children[key]
|
||||
if (!existing) return
|
||||
children.mark(key)
|
||||
if (
|
||||
event.current?.type === "session.moved" ||
|
||||
event.current?.type === "session.archived" ||
|
||||
event.current?.type === "session.forked" ||
|
||||
eventType === "command.updated" ||
|
||||
eventType === "config.updated" ||
|
||||
eventType === "agent.updated"
|
||||
)
|
||||
queue.push(key)
|
||||
if (eventType === "mcp.status.changed") void queryClient.invalidateQueries(queryOptionsApi.mcp(key))
|
||||
if (eventType === "mcp.resources.changed") void queryClient.invalidateQueries(queryOptionsApi.mcpResources(key))
|
||||
const [store, setStore] = existing
|
||||
applyDirectoryEvent({
|
||||
event,
|
||||
@@ -502,14 +686,23 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
toggle: async (directory: string, name: string) => {
|
||||
const key = directoryKey(directory)
|
||||
const sdk = sdkFor(key)
|
||||
const status = children.child(key, { bootstrap: false })[0].mcp[name].status
|
||||
const status = children.child(key, { bootstrap: false })[0].mcp[name]?.status
|
||||
if (!status) return
|
||||
await toggleMcp({
|
||||
status,
|
||||
connect: async () => {
|
||||
await sdk.mcp.connect({ name })
|
||||
if ((await serverSDK.protocol) === "v1") {
|
||||
await sdk.mcp.connect({ name })
|
||||
return
|
||||
}
|
||||
await serverSDK.api.mcp.connect({ server: name, location: { directory: key } })
|
||||
},
|
||||
disconnect: async () => {
|
||||
await sdk.mcp.disconnect({ name })
|
||||
if ((await serverSDK.protocol) === "v1") {
|
||||
await sdk.mcp.disconnect({ name })
|
||||
return
|
||||
}
|
||||
await serverSDK.api.mcp.disconnect({ server: name, location: { directory: key } })
|
||||
},
|
||||
authenticate: async () => {
|
||||
await sdk.mcp.auth.authenticate({ name })
|
||||
|
||||
@@ -19,7 +19,7 @@ test("archiving a Home session removes its open titlebar tab", async () => {
|
||||
await archiveHomeSession({
|
||||
server: remote,
|
||||
session: { id: "ses_1", directory: "/workspace" },
|
||||
update: async () => undefined,
|
||||
archive: async () => undefined,
|
||||
remove: () => {
|
||||
removed = true
|
||||
},
|
||||
@@ -37,7 +37,7 @@ test("reports archive failures without removing the session", async () => {
|
||||
await archiveHomeSession({
|
||||
server: remote,
|
||||
session: { id: "ses_1", directory: "/workspace" },
|
||||
update: async () => Promise.reject(failure),
|
||||
archive: async () => Promise.reject(failure),
|
||||
remove: () => {
|
||||
removed = true
|
||||
},
|
||||
|
||||
@@ -6,25 +6,15 @@ type HomeSession = {
|
||||
directory: string
|
||||
}
|
||||
|
||||
type SessionUpdate = {
|
||||
directory: string
|
||||
sessionID: string
|
||||
time: { archived: number }
|
||||
}
|
||||
|
||||
export async function archiveHomeSession(input: {
|
||||
server: ServerConnection.Key
|
||||
session: HomeSession
|
||||
update: (value: SessionUpdate) => Promise<unknown>
|
||||
archive: (sessionID: string) => Promise<unknown>
|
||||
remove: () => void
|
||||
onError?: (error: unknown) => void
|
||||
}) {
|
||||
await input
|
||||
.update({
|
||||
directory: input.session.directory,
|
||||
sessionID: input.session.id,
|
||||
time: { archived: Date.now() },
|
||||
})
|
||||
.archive(input.session.id)
|
||||
.then(() => {
|
||||
input.remove()
|
||||
notifySessionTabsRemoved({
|
||||
|
||||
+39
-1915
@@ -1,1926 +1,50 @@
|
||||
import type { Session } from "@opencode-ai/sdk/v2/client"
|
||||
import {
|
||||
type ComponentProps,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createResource,
|
||||
createRoot,
|
||||
createSignal,
|
||||
For,
|
||||
Match,
|
||||
on,
|
||||
onCleanup,
|
||||
onMount,
|
||||
Show,
|
||||
startTransition,
|
||||
Switch,
|
||||
} from "solid-js"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
|
||||
import { isSortable, useSortable } from "@dnd-kit/solid/sortable"
|
||||
import { AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/dom"
|
||||
import { RestrictToVerticalAxis } from "@dnd-kit/abstract/modifiers"
|
||||
import { RestrictToElement } from "@dnd-kit/dom/modifiers"
|
||||
import { useQuery } from "@tanstack/solid-query"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Logo } from "@opencode-ai/ui/logo"
|
||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
|
||||
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 { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { getProjectAvatarVariant, useLayout, type HomeProjectSelection, type LocalProject } from "@/context/layout"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { DateTime } from "luxon"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useDirectoryPicker } from "@/components/directory-picker"
|
||||
import { useSettingsCommand } from "@/components/settings-dialog"
|
||||
import { DialogSelectServer, useServerManagementController } from "@/components/dialog-select-server"
|
||||
import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2"
|
||||
import { ServerConnection, serverName, useServer } from "@/context/server"
|
||||
import { sessionHasOpenTab, useTabs } from "@/context/tabs"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useNotification } from "@/context/notification"
|
||||
import {
|
||||
closeHomeProject,
|
||||
displayName,
|
||||
errorMessage,
|
||||
getProjectAvatarSource,
|
||||
homeProjectDirectories,
|
||||
projectForSession,
|
||||
toggleHomeProjectSelection,
|
||||
} from "@/pages/layout/helpers"
|
||||
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
|
||||
import { sessionTitle } from "@/utils/session-title"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { Binary } from "@opencode-ai/core/util/binary"
|
||||
import { ServerRowMenu } from "@/components/server/server-row-menu"
|
||||
import { ServerHealthIndicator } from "@/components/server/server-row"
|
||||
import { type ServerHealth } from "@/utils/server-health"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { useMarked } from "@opencode-ai/ui/context/marked"
|
||||
import { preloadMarkdown } from "@opencode-ai/session-ui/markdown-cache"
|
||||
import { archiveHomeSession } from "./home-session-archive"
|
||||
import { shouldOpenSessionInBackground } from "./home-session-open"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { fileManagerApp } from "@/utils/file-manager"
|
||||
import {
|
||||
loadHomeSessionIndex,
|
||||
retainHomeSessions,
|
||||
type HomeSessionEvents,
|
||||
} from "@/context/global-sync/home-session-index"
|
||||
|
||||
const HOME_SESSION_LIMIT = 64
|
||||
const HOME_SESSION_HEADER_STICKY_TOP = 12
|
||||
const HOME_SESSION_HEADER_TEXT_HEIGHT = 16
|
||||
const HOME_SESSION_HEADER_FADE_DISTANCE = 16
|
||||
|
||||
function containHomeWheel(event: WheelEvent, viewport: HTMLElement) {
|
||||
if (event.defaultPrevented || event.ctrlKey || !event.deltaY) return
|
||||
if (!(event.target instanceof Element)) return
|
||||
|
||||
const scrollable = event.target.closest<HTMLElement>("[data-scrollable]")
|
||||
if (
|
||||
scrollable !== viewport &&
|
||||
scrollable &&
|
||||
(event.deltaY < 0
|
||||
? scrollable.scrollTop > 0
|
||||
: scrollable.scrollTop < scrollable.scrollHeight - scrollable.clientHeight)
|
||||
)
|
||||
return
|
||||
|
||||
event.preventDefault()
|
||||
}
|
||||
const SHOW_HOME_SESSION_ARCHIVE = false
|
||||
const HOME_ROW_LAYOUT =
|
||||
"flex min-w-0 w-full shrink-0 cursor-default items-center rounded-[6px] bg-transparent text-left transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out focus-visible:outline-none"
|
||||
const HOME_ROW_BASE = `${HOME_ROW_LAYOUT} border-0`
|
||||
const HOME_ROW = `${HOME_ROW_BASE} [font-weight:530] text-v2-text-text-muted hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover`
|
||||
const HOME_PROJECT_NAV_LABEL = "min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
const HOME_PROJECT_NAV_ROW = `${HOME_ROW_LAYOUT} h-7 gap-2 px-1.5 [font-weight:440] text-v2-text-text-muted hover:bg-v2-background-bg-layer-01 hover:text-v2-text-text-base hover:[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)] data-[selected]:bg-v2-background-bg-layer-03 data-[selected]:text-v2-text-text-base data-[selected]:[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)] data-[selected]:hover:bg-v2-background-bg-layer-03 focus-visible:bg-v2-background-bg-layer-01 focus-visible:text-v2-text-text-base focus-visible:[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)]`
|
||||
const HOME_SECTION_LABEL = "text-v2-text-text-muted [font-weight:440]"
|
||||
|
||||
type HomeSessionRecord = {
|
||||
session: Session
|
||||
project: LocalProject
|
||||
projectName: string
|
||||
}
|
||||
|
||||
type HomeSessionGroup = {
|
||||
id: "today" | "yesterday" | "older"
|
||||
title: string
|
||||
sessions: HomeSessionRecord[]
|
||||
}
|
||||
|
||||
const HOME_SESSION_SEARCH_RESULTS_ID = "home-session-search-results"
|
||||
const HOME_SEARCH_RESULT_ROW =
|
||||
"flex h-10 w-full shrink-0 cursor-default items-center gap-2 border-0 py-3 pl-[18px] pr-6 text-left transition-[background-color] duration-[120ms] ease-in-out hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
|
||||
const HOME_SEARCH_RESULT_TITLE =
|
||||
"min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-[13px] leading-4 tracking-[-0.04px] text-v2-text-text-base [font-weight:530]"
|
||||
const HOME_SEARCH_RESULT_META =
|
||||
"min-w-0 flex-[1_1_auto] overflow-hidden text-ellipsis whitespace-nowrap text-[13px] leading-4 tracking-[-0.04px] text-v2-text-text-muted [font-weight:440]"
|
||||
|
||||
let pendingHomeNavigation: { server: ServerConnection.Key; href: string } | undefined
|
||||
|
||||
function buildHomeSessionRecords(input: {
|
||||
sessions: () => Session[]
|
||||
projectDirectories: () => string[]
|
||||
projects: () => LocalProject[]
|
||||
projectByID: () => Map<string, LocalProject>
|
||||
}) {
|
||||
const directories = new Set(input.projectDirectories().map(pathKey))
|
||||
const sessions = input.sessions().filter((session) => directories.has(pathKey(session.directory)))
|
||||
return [...new Map(sessions.map((session) => [session.id, session] as const)).values()]
|
||||
.sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
|
||||
.flatMap((session) => {
|
||||
const directory = pathKey(session.directory)
|
||||
const project =
|
||||
input
|
||||
.projects()
|
||||
.find(
|
||||
(item) =>
|
||||
pathKey(item.worktree) === directory || item.sandboxes?.some((sandbox) => pathKey(sandbox) === directory),
|
||||
) ?? projectForSession(session, input.projects(), input.projectByID())
|
||||
if (!project) return []
|
||||
return {
|
||||
session,
|
||||
project,
|
||||
projectName: displayName(project),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function matchesHomeSessionSearch(record: HomeSessionRecord, query: string) {
|
||||
return `${record.session.title} ${record.projectName}`.toLowerCase().includes(query)
|
||||
}
|
||||
|
||||
function homeSessionSearchKey(record: HomeSessionRecord) {
|
||||
return `${pathKey(record.session.directory)}:${record.session.id}`
|
||||
}
|
||||
|
||||
function useHomeSessionHeaderOpacity(groups: () => HomeSessionGroup[]) {
|
||||
let viewport: HTMLDivElement | undefined
|
||||
let content: HTMLDivElement | undefined
|
||||
let positionFrame: number | undefined
|
||||
let resizeObserver: ResizeObserver | undefined
|
||||
let stickyTop = HOME_SESSION_HEADER_STICKY_TOP
|
||||
const headerRefs = new Map<HomeSessionGroup["id"], HTMLDivElement>()
|
||||
const headerOffsets = new Map<HomeSessionGroup["id"], number>()
|
||||
const [state, setState] = createStore({
|
||||
titleOpacity: {} as Partial<Record<HomeSessionGroup["id"], number>>,
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const items = groups()
|
||||
const ids = new Set(items.map((group) => group.id))
|
||||
headerRefs.forEach((_, id) => {
|
||||
if (!ids.has(id)) headerRefs.delete(id)
|
||||
})
|
||||
headerOffsets.forEach((_, id) => {
|
||||
if (!ids.has(id)) headerOffsets.delete(id)
|
||||
})
|
||||
if (items.length === 0) {
|
||||
content = undefined
|
||||
bindResizeObserver()
|
||||
}
|
||||
queuePositionUpdate()
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (positionFrame !== undefined) cancelAnimationFrame(positionFrame)
|
||||
resizeObserver?.disconnect()
|
||||
})
|
||||
|
||||
function setViewport(el: HTMLDivElement) {
|
||||
viewport = el
|
||||
bindResizeObserver()
|
||||
queuePositionUpdate()
|
||||
}
|
||||
|
||||
function setContentRef(el: HTMLDivElement) {
|
||||
content = el
|
||||
bindResizeObserver()
|
||||
queuePositionUpdate()
|
||||
}
|
||||
|
||||
function setHeaderRef(id: HomeSessionGroup["id"], el: HTMLDivElement) {
|
||||
headerRefs.set(id, el)
|
||||
queuePositionUpdate()
|
||||
}
|
||||
|
||||
function queuePositionUpdate() {
|
||||
if (typeof requestAnimationFrame === "undefined") {
|
||||
updatePositionCache()
|
||||
return
|
||||
}
|
||||
if (positionFrame !== undefined) return
|
||||
positionFrame = requestAnimationFrame(() => {
|
||||
positionFrame = undefined
|
||||
updatePositionCache()
|
||||
})
|
||||
}
|
||||
|
||||
function updatePositionCache() {
|
||||
if (!viewport) return
|
||||
const header = groups()
|
||||
.map((group) => headerRefs.get(group.id))
|
||||
.find((el) => el !== undefined)
|
||||
if (header && typeof getComputedStyle === "function") {
|
||||
const top = Number.parseFloat(getComputedStyle(header).top)
|
||||
if (Number.isFinite(top)) stickyTop = top
|
||||
}
|
||||
groups().forEach((group) => {
|
||||
const el = headerRefs.get(group.id)
|
||||
if (!el) return
|
||||
headerOffsets.set(group.id, el.offsetTop)
|
||||
})
|
||||
update(viewport.scrollTop)
|
||||
}
|
||||
|
||||
function update(scrollTop: number) {
|
||||
const items = groups()
|
||||
items.forEach((group, index) => {
|
||||
const nextOffset = items
|
||||
.slice(index + 1)
|
||||
.map((item) => headerOffsets.get(item.id))
|
||||
.find((offset) => offset !== undefined)
|
||||
const fadeEnd = stickyTop + HOME_SESSION_HEADER_TEXT_HEIGHT
|
||||
const nextTop = nextOffset === undefined ? undefined : nextOffset - scrollTop
|
||||
const opacity =
|
||||
nextTop === undefined ? 1 : Math.max(0, Math.min(1, (nextTop - fadeEnd) / HOME_SESSION_HEADER_FADE_DISTANCE))
|
||||
setState("titleOpacity", group.id, Math.round(opacity * 1000) / 1000)
|
||||
})
|
||||
}
|
||||
|
||||
function titleOpacity(id: HomeSessionGroup["id"]) {
|
||||
return state.titleOpacity[id] ?? 1
|
||||
}
|
||||
|
||||
function bindResizeObserver() {
|
||||
resizeObserver?.disconnect()
|
||||
if (typeof ResizeObserver === "undefined") return
|
||||
resizeObserver = new ResizeObserver(() => queuePositionUpdate())
|
||||
if (viewport) resizeObserver.observe(viewport)
|
||||
if (content) resizeObserver.observe(content)
|
||||
}
|
||||
|
||||
return { setViewport, setContentRef, setHeaderRef, update, titleOpacity }
|
||||
}
|
||||
|
||||
// Middle-click or Cmd+click on macOS (Ctrl+click elsewhere) opens a session
|
||||
// tab in the background without navigating, matching browser conventions.
|
||||
function isBackgroundOpen(event: MouseEvent) {
|
||||
return shouldOpenSessionInBackground({
|
||||
button: event.button,
|
||||
mac: typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform),
|
||||
meta: event.metaKey,
|
||||
ctrl: event.ctrlKey,
|
||||
shift: event.shiftKey,
|
||||
alt: event.altKey,
|
||||
})
|
||||
}
|
||||
|
||||
type OpenSessionOptions = { background?: boolean }
|
||||
import { createHomeController } from "./home/home-controller"
|
||||
import { createHomeProjectsController } from "./home/home-projects-controller"
|
||||
import { HomeUtilityNav } from "./home/home-projects-view"
|
||||
import { HomeProjects } from "./home/home-projects"
|
||||
import { createHomeScrollController } from "./home/home-scroll-controller"
|
||||
import { createHomeSessionSearchController } from "./home/home-session-search-controller"
|
||||
import { createHomeSessionsController } from "./home/home-sessions-controller"
|
||||
import { HomeSessions } from "./home/home-sessions"
|
||||
|
||||
export function NewHome() {
|
||||
const sync = useServerSync()
|
||||
const layout = useLayout()
|
||||
const platform = usePlatform()
|
||||
const pickDirectory = useDirectoryPicker()
|
||||
const dialog = useDialog()
|
||||
const navigate = useNavigate()
|
||||
const server = useServer()
|
||||
const language = useLanguage()
|
||||
const global = useGlobal()
|
||||
const tabs = useTabs()
|
||||
const command = useCommand()
|
||||
const notification = useNotification()
|
||||
const marked = useMarked()
|
||||
const openSettings = useSettingsCommand()
|
||||
let focusSessionSearch: (() => void) | undefined
|
||||
let sessionViewport: HTMLDivElement | undefined
|
||||
const [sessionThumbTrack, setSessionThumbTrack] = createSignal<HTMLDivElement>()
|
||||
const [sessionHoverTarget, setSessionHoverTarget] = createSignal<HTMLElement>()
|
||||
const [state, setState] = createStore({
|
||||
search: "",
|
||||
searchFocused: false,
|
||||
})
|
||||
const selection = layout.home.selection
|
||||
|
||||
const focusedServer = createMemo(
|
||||
() => global.servers.list().find((conn) => ServerConnection.key(conn) === selection().server) ?? server.current,
|
||||
)
|
||||
const focusedServerCtx = createMemo(() => {
|
||||
const conn = focusedServer()
|
||||
if (!conn) return
|
||||
return global.ensureServerCtx(conn)
|
||||
})
|
||||
const focusedSync = () => focusedServerCtx()?.sync ?? sync()
|
||||
const homeSessions = () => focusedSync().homeSessions
|
||||
const projects = createMemo(() => focusedServerCtx()?.projects.list() ?? layout.projects.list())
|
||||
const recentlyClosed = createMemo(
|
||||
() => focusedServerCtx()?.projects.recentlyClosed() ?? layout.projects.recentlyClosed(),
|
||||
)
|
||||
const homedir = createMemo(() => focusedSync().data.path.home ?? "")
|
||||
const selectedProject = createMemo(() => projects().find((project) => project.worktree === selection().directory))
|
||||
const newSessionProject = createMemo(
|
||||
() =>
|
||||
selectedProject() ??
|
||||
projects().find((project) => project.worktree === focusedServerCtx()?.projects.last()) ??
|
||||
projects()[0],
|
||||
)
|
||||
const directories = (project: LocalProject) => [project.worktree, ...(project.sandboxes ?? [])]
|
||||
const projectDirectories = createMemo(() => {
|
||||
const project = selectedProject()
|
||||
if (!project) return projects().flatMap(directories)
|
||||
return directories(project)
|
||||
})
|
||||
const search = createMemo(() => state.search.trim())
|
||||
const searchPlaceholder = createMemo(() => {
|
||||
const project = selectedProject()
|
||||
if (project) {
|
||||
return language.t("home.sessions.search.placeholder.scoped", { scope: displayName(project) })
|
||||
}
|
||||
if (global.servers.list().length > 1) {
|
||||
const conn = focusedServer()
|
||||
if (conn) {
|
||||
return language.t("home.sessions.search.placeholder.scoped", { scope: serverName(conn) })
|
||||
}
|
||||
}
|
||||
return language.t("home.sessions.search.placeholder")
|
||||
})
|
||||
const sessionEventLoad = useQuery(() => ({
|
||||
queryKey: homeSessions().eventsKey,
|
||||
queryFn: async (): Promise<HomeSessionEvents> => ({ sequence: 0, entries: [] }),
|
||||
initialData: { sequence: 0, entries: [] } satisfies HomeSessionEvents,
|
||||
enabled: false,
|
||||
}))
|
||||
const sessionLoad = useQuery(() => ({
|
||||
queryKey: homeSessions().indexKey,
|
||||
enabled: !!focusedServerCtx(),
|
||||
queryFn: async ({ signal }) => {
|
||||
const ctx = focusedServerCtx()
|
||||
if (!ctx) return { sessions: [], eventSequence: 0 }
|
||||
const cache = homeSessions()
|
||||
const eventSequence = cache.eventSequence()
|
||||
const index = await loadHomeSessionIndex(
|
||||
(input, options) => ctx.sdk.client.v2.session.list(input, options),
|
||||
eventSequence,
|
||||
signal,
|
||||
)
|
||||
cache.complete(eventSequence)
|
||||
return index
|
||||
},
|
||||
retry: false,
|
||||
staleTime: 30_000,
|
||||
refetchOnMount: true,
|
||||
refetchOnReconnect: true,
|
||||
}))
|
||||
|
||||
const projectByID = createMemo(
|
||||
() => new Map(projects().flatMap((project) => (project.id ? [[project.id, project] as const] : []))),
|
||||
)
|
||||
const indexedSessions = createMemo(() =>
|
||||
retainHomeSessions(
|
||||
homeSessions().sessions(sessionLoad.data, sessionEventLoad.data),
|
||||
HOME_SESSION_LIMIT,
|
||||
Date.now(),
|
||||
),
|
||||
)
|
||||
const allRecords = createMemo(() =>
|
||||
buildHomeSessionRecords({
|
||||
sessions: indexedSessions,
|
||||
projectDirectories,
|
||||
projects,
|
||||
projectByID,
|
||||
}),
|
||||
)
|
||||
const records = createMemo(() => allRecords().slice(0, HOME_SESSION_LIMIT))
|
||||
const searchResults = createMemo(() => {
|
||||
const query = search().toLowerCase()
|
||||
if (!query) return []
|
||||
return allRecords().filter((record) => matchesHomeSessionSearch(record, query))
|
||||
})
|
||||
const searchOpen = createMemo(() => state.searchFocused && search().length > 0)
|
||||
const groups = createMemo(() => groupSessions(records(), language))
|
||||
const sessionHeaderOpacity = useHomeSessionHeaderOpacity(groups)
|
||||
const prefetched = new Set<string>()
|
||||
|
||||
createEffect(() => {
|
||||
const ctx = focusedServerCtx()
|
||||
if (!ctx) return
|
||||
records()
|
||||
.slice(0, 2)
|
||||
.forEach((record) => {
|
||||
const key = `${ServerConnection.key(focusedServer()!)}\0${record.session.id}`
|
||||
if (prefetched.has(key)) return
|
||||
prefetched.add(key)
|
||||
createRoot((dispose) => {
|
||||
try {
|
||||
void ctx.sync.session
|
||||
.sync(record.session.id)
|
||||
.then(() => {
|
||||
return Promise.all(
|
||||
(ctx.sync.session.data.message[record.session.id] ?? []).flatMap((message) =>
|
||||
(ctx.sync.session.data.part[message.id] ?? []).flatMap((part) => {
|
||||
if (part.type !== "text" || !part.text) return []
|
||||
return preloadMarkdown(part.text, part.id, marked)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(dispose)
|
||||
} catch {
|
||||
dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
function setSelection(next: HomeProjectSelection) {
|
||||
layout.home.setSelection(next)
|
||||
}
|
||||
|
||||
function closeSearch() {
|
||||
setState("search", "")
|
||||
setState("searchFocused", false)
|
||||
}
|
||||
|
||||
function selectSearchSession(session: Session, options?: OpenSessionOptions) {
|
||||
openSession(session, options)
|
||||
// Background opens keep the search visible so several results can be
|
||||
// opened in a row.
|
||||
if (!options?.background) closeSearch()
|
||||
}
|
||||
|
||||
command.register("home", () => [
|
||||
{
|
||||
id: "command.palette",
|
||||
title: language.t("command.palette"),
|
||||
hidden: true,
|
||||
onSelect: async () => {
|
||||
const conn = focusedServer()
|
||||
if (!conn) return
|
||||
const ctx = global.ensureServerCtx(conn)
|
||||
const { DialogHomeCommandPaletteV2 } = await import("@/components/dialog-command-palette-v2")
|
||||
void dialog.show(() => (
|
||||
<DialogHomeCommandPaletteV2
|
||||
server={conn}
|
||||
onSelectSession={(entry) => {
|
||||
if (!entry.sessionID || !entry.directory || !entry.server) return
|
||||
const sessionID = entry.sessionID
|
||||
const server = entry.server
|
||||
const directory = entry.project?.worktree ?? entry.directory
|
||||
ctx.projects.open(directory)
|
||||
ctx.projects.touch(directory)
|
||||
void startTransition(() => {
|
||||
const tab = tabs.addSessionTab({ server, sessionId: sessionID })
|
||||
tabs.select(tab)
|
||||
})
|
||||
}}
|
||||
/>
|
||||
))
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "home.sessions.search.focus",
|
||||
title: searchPlaceholder(),
|
||||
keybind: "mod+f",
|
||||
hidden: true,
|
||||
onSelect: () => focusSessionSearch?.(),
|
||||
},
|
||||
])
|
||||
|
||||
createEffect(() => {
|
||||
const list = global.servers.list()
|
||||
if (list.some((conn) => ServerConnection.key(conn) === selection().server)) return
|
||||
const conn = list.find((conn) => ServerConnection.key(conn) === server.key) ?? list[0]
|
||||
if (conn) setSelection({ server: ServerConnection.key(conn) })
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const pending = pendingHomeNavigation
|
||||
if (!pending || pending.server !== server.key) return
|
||||
pendingHomeNavigation = undefined
|
||||
navigate(pending.href)
|
||||
})
|
||||
|
||||
function focusServer(conn: ServerConnection.Any) {
|
||||
setSelection({ server: ServerConnection.key(conn) })
|
||||
}
|
||||
|
||||
function selectProject(conn: ServerConnection.Any, directory: string) {
|
||||
const key = ServerConnection.key(conn)
|
||||
if (global.servers.health[key]?.healthy === false) return
|
||||
if (
|
||||
!global
|
||||
.ensureServerCtx(conn)
|
||||
.projects.list()
|
||||
.some((project) => project.worktree === directory)
|
||||
)
|
||||
return
|
||||
setSelection(toggleHomeProjectSelection(selection(), key, directory))
|
||||
}
|
||||
|
||||
function addProjects(conn: ServerConnection.Any, directories: string[]) {
|
||||
const directory = directories[0]
|
||||
if (!directory) return
|
||||
const ctx = global.ensureServerCtx(conn)
|
||||
directories.forEach(ctx.projects.open)
|
||||
ctx.projects.touch(directory)
|
||||
setSelection({ server: ServerConnection.key(conn), directory })
|
||||
}
|
||||
|
||||
function openNewSession() {
|
||||
const conn = focusedServer()
|
||||
const project = newSessionProject()
|
||||
if (!conn || !project) return
|
||||
openProjectNewSession(conn, project.worktree)
|
||||
}
|
||||
|
||||
function openProjectNewSession(conn: ServerConnection.Any, directory: string) {
|
||||
const ctx = global.ensureServerCtx(conn)
|
||||
ctx.projects.open(directory)
|
||||
ctx.projects.touch(directory)
|
||||
tabs.newDraft({ server: ServerConnection.key(conn), directory })
|
||||
}
|
||||
|
||||
function editProject(conn: ServerConnection.Any, project: LocalProject) {
|
||||
void import("@/components/dialog-edit-project-v2").then((x) => {
|
||||
void dialog.show(() => <x.DialogEditProjectV2 server={conn} project={project} />)
|
||||
})
|
||||
}
|
||||
|
||||
function unseenCount(conn: ServerConnection.Any, project: LocalProject) {
|
||||
const state = notification.ensureServerState(ServerConnection.key(conn))
|
||||
return directories(project).reduce((total, directory) => total + state.project.unseenCount(directory), 0)
|
||||
}
|
||||
|
||||
function clearNotifications(conn: ServerConnection.Any, project: LocalProject) {
|
||||
const state = notification.ensureServerState(ServerConnection.key(conn))
|
||||
directories(project)
|
||||
.filter((directory) => state.project.unseenCount(directory) > 0)
|
||||
.forEach((directory) => state.project.markViewed(directory))
|
||||
}
|
||||
|
||||
function openSession(session: Session, options?: OpenSessionOptions) {
|
||||
const directoryKey = pathKey(session.directory)
|
||||
const project =
|
||||
projects().find(
|
||||
(item) =>
|
||||
pathKey(item.worktree) === directoryKey ||
|
||||
item.sandboxes?.some((sandbox) => pathKey(sandbox) === directoryKey),
|
||||
) ?? projectForSession(session, projects(), projectByID())
|
||||
const conn = focusedServer()
|
||||
if (!conn) return
|
||||
const directory = project?.worktree ?? session.directory
|
||||
const ctx = global.ensureServerCtx(conn)
|
||||
ctx.projects.open(directory)
|
||||
if (options?.background) {
|
||||
tabs.addSessionTab({ server: ServerConnection.key(conn), sessionId: session.id })
|
||||
return
|
||||
}
|
||||
ctx.projects.touch(directory)
|
||||
startTransition(() => {
|
||||
const tab = tabs.addSessionTab({ server: ServerConnection.key(conn), sessionId: session.id })
|
||||
tabs.select(tab)
|
||||
})
|
||||
}
|
||||
|
||||
async function archiveSession(session: Session) {
|
||||
const conn = focusedServer()
|
||||
const ctx = focusedServerCtx()
|
||||
if (!conn || !ctx) return
|
||||
const [, setStore] = ctx.sync.child(session.directory)
|
||||
await archiveHomeSession({
|
||||
server: ServerConnection.key(conn),
|
||||
session,
|
||||
update: (value) => ctx.sdk.client.session.update(value),
|
||||
remove: () =>
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
const match = Binary.search(draft.session, session.id, (s) => s.id)
|
||||
if (match.found) draft.session.splice(match.index, 1)
|
||||
}),
|
||||
),
|
||||
onError: (error) =>
|
||||
showToast({
|
||||
title: language.t("common.requestFailed"),
|
||||
description: errorMessage(error, language.t("common.requestFailed")),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
function chooseProject(conn: ServerConnection.Any) {
|
||||
if (global.servers.health[ServerConnection.key(conn)]?.healthy === false) return
|
||||
|
||||
function resolve(result: string | string[] | null) {
|
||||
addProjects(conn, homeProjectDirectories(result))
|
||||
}
|
||||
|
||||
pickDirectory({
|
||||
server: conn,
|
||||
title: language.t("command.project.open"),
|
||||
multiple: true,
|
||||
onSelect: resolve,
|
||||
})
|
||||
}
|
||||
|
||||
const home = createHomeController()
|
||||
const projects = createHomeProjectsController(home)
|
||||
const sessions = createHomeSessionsController(home)
|
||||
const search = createHomeSessionSearchController(home, sessions)
|
||||
const scroll = createHomeScrollController(sessions.data.groups)
|
||||
return (
|
||||
<div class="rounded-[10px] shadow-[var(--v2-elevation-raised)] m-2 min-h-0 overflow-hidden bg-v2-background-bg-base self-stretch flex-1">
|
||||
<div
|
||||
class={`
|
||||
m-2 min-h-0 flex-1 self-stretch overflow-hidden rounded-[10px]
|
||||
bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]
|
||||
`}
|
||||
>
|
||||
<ScrollView
|
||||
class="h-full [container-type:size]"
|
||||
thumbContainer={sessionThumbTrack}
|
||||
thumbHoverTarget={sessionHoverTarget}
|
||||
viewportRef={(el) => {
|
||||
sessionViewport = el
|
||||
sessionHeaderOpacity.setViewport(el)
|
||||
}}
|
||||
onScroll={(event) => sessionHeaderOpacity.update(event.currentTarget.scrollTop)}
|
||||
onWheel={(event) => {
|
||||
if (!sessionViewport) return
|
||||
if (event.target instanceof Node && sessionViewport.contains(event.target)) return
|
||||
containHomeWheel(event, sessionViewport)
|
||||
}}
|
||||
>
|
||||
<div class="mx-auto grid min-h-full w-full max-w-[1080px] grid-rows-[auto_minmax(0,1fr)_auto] gap-4 px-3 lg:grid-cols-[280px_minmax(0,720px)] lg:grid-rows-1 lg:gap-8 lg:px-6">
|
||||
<HomeProjectColumn
|
||||
projects={projects()}
|
||||
recentlyClosed={recentlyClosed()}
|
||||
homedir={homedir()}
|
||||
selected={selection()}
|
||||
focusServer={focusServer}
|
||||
selectProject={selectProject}
|
||||
openNewSession={openProjectNewSession}
|
||||
openRecentProject={(conn, directory) => addProjects(conn, [directory])}
|
||||
chooseProject={(conn) => void chooseProject(conn)}
|
||||
editProject={editProject}
|
||||
closeProject={(conn, directory) => {
|
||||
const next = closeHomeProject(
|
||||
selection(),
|
||||
ServerConnection.key(conn),
|
||||
global.ensureServerCtx(conn).projects,
|
||||
directory,
|
||||
)
|
||||
if (next) setSelection(next)
|
||||
}}
|
||||
clearNotifications={clearNotifications}
|
||||
unseenCount={unseenCount}
|
||||
openSettings={openSettings}
|
||||
openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
|
||||
language={language}
|
||||
onWheel={(event) => {
|
||||
if (sessionViewport) containHomeWheel(event, sessionViewport)
|
||||
}}
|
||||
/>
|
||||
|
||||
<section
|
||||
ref={setSessionHoverTarget}
|
||||
class="min-h-0 min-w-0 flex-1 flex flex-col"
|
||||
aria-label={language.t("sidebar.project.recentSessions")}
|
||||
>
|
||||
<div
|
||||
class="sticky top-0 z-30 shrink-0 bg-v2-background-bg-base pb-3 pt-6 lg:pt-12"
|
||||
onWheel={(event) => {
|
||||
if (sessionViewport) containHomeWheel(event, sessionViewport)
|
||||
}}
|
||||
>
|
||||
<HomeSessionSearch
|
||||
value={state.search}
|
||||
placeholder={searchPlaceholder()}
|
||||
open={searchOpen()}
|
||||
loading={sessionLoad.isLoading}
|
||||
results={searchResults()}
|
||||
showProjectName={!selectedProject()}
|
||||
server={selection().server}
|
||||
noResultsLabel={language.t("home.sessions.search.noResults", { query: search() })}
|
||||
bindFocus={(focus) => {
|
||||
focusSessionSearch = focus
|
||||
}}
|
||||
onInput={(value) => setState("search", value)}
|
||||
onFocus={() => setState("searchFocused", true)}
|
||||
onClose={closeSearch}
|
||||
onSelect={selectSearchSession}
|
||||
/>
|
||||
<Show when={groups().length > 0 && newSessionProject()}>
|
||||
<div class="pointer-events-none absolute right-0 top-[84px] z-20 flex lg:top-[108px]">
|
||||
<ButtonV2
|
||||
data-action="home-new-session"
|
||||
variant="ghost-muted"
|
||||
size="normal"
|
||||
icon="edit"
|
||||
class="pointer-events-auto h-7 px-2 [font-weight:530]"
|
||||
onClick={openNewSession}
|
||||
>
|
||||
{language.t("command.session.new")}
|
||||
</ButtonV2>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
{/* Sticky chrome for the portaled session scrollbar — matches old sessions ScrollView bounds */}
|
||||
<div class="pointer-events-none sticky top-[84px] z-40 h-0 -mr-3 lg:top-[108px]">
|
||||
<div
|
||||
ref={setSessionThumbTrack}
|
||||
data-component="home-session-scroll-track"
|
||||
class="relative ml-auto h-[calc(100cqh-84px)] w-3 lg:h-[calc(100cqh-108px)]"
|
||||
/>
|
||||
</div>
|
||||
<div class="-mr-3 min-h-[calc(100cqh-72px)] lg:min-h-[calc(100cqh-96px)]">
|
||||
<Show
|
||||
when={!sessionLoad.isLoading}
|
||||
fallback={
|
||||
<div class="pt-3">
|
||||
<HomeSessionSkeleton label={language.t("common.loading")} />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={groups().length > 0}
|
||||
fallback={<HomeSessionsEmpty onNewSession={newSessionProject() ? openNewSession : undefined} />}
|
||||
>
|
||||
<div ref={sessionHeaderOpacity.setContentRef} class="flex flex-col pt-3 pr-3 pb-16">
|
||||
<For each={groups()}>
|
||||
{(group, index) => (
|
||||
<>
|
||||
<HomeSessionGroupHeader
|
||||
title={group.title}
|
||||
titleOpacity={sessionHeaderOpacity.titleOpacity(group.id)}
|
||||
ref={(el) => sessionHeaderOpacity.setHeaderRef(group.id, el)}
|
||||
elevated={index() === 0}
|
||||
/>
|
||||
<div
|
||||
class={`flex min-w-0 flex-col gap-px pt-4 ${index() === groups().length - 1 ? "" : "mb-6"}`}
|
||||
>
|
||||
<For each={group.sessions}>
|
||||
{(record) => (
|
||||
<HomeSessionRow
|
||||
record={record}
|
||||
showProjectName={!selectedProject()}
|
||||
server={selection().server}
|
||||
openSession={openSession}
|
||||
archiveSession={archiveSession}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</section>
|
||||
<HomeUtilityNav
|
||||
class="flex lg:hidden"
|
||||
openSettings={openSettings}
|
||||
openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
|
||||
language={language}
|
||||
/>
|
||||
</div>
|
||||
</ScrollView>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeProjectColumn(props: {
|
||||
projects: LocalProject[]
|
||||
recentlyClosed: LocalProject[]
|
||||
homedir: string
|
||||
selected: HomeProjectSelection
|
||||
focusServer: (server: ServerConnection.Any) => void
|
||||
selectProject: (server: ServerConnection.Any, directory: string) => void
|
||||
openNewSession: (server: ServerConnection.Any, directory: string) => void
|
||||
openRecentProject: (server: ServerConnection.Any, directory: string) => void
|
||||
chooseProject: (server: ServerConnection.Any) => void
|
||||
editProject: (server: ServerConnection.Any, project: LocalProject) => void
|
||||
closeProject: (server: ServerConnection.Any, directory: string) => void
|
||||
clearNotifications: (server: ServerConnection.Any, project: LocalProject) => void
|
||||
unseenCount: (server: ServerConnection.Any, project: LocalProject) => number
|
||||
openSettings: () => void
|
||||
openHelp: () => void
|
||||
language: ReturnType<typeof useLanguage>
|
||||
onWheel: (event: WheelEvent) => void
|
||||
}) {
|
||||
const global = useGlobal()
|
||||
const dialog = useDialog()
|
||||
const controller = useServerManagementController({ navigateOnAdd: false })
|
||||
const [_state, setState, _, ready] = persisted(
|
||||
Persist.global("home.servers", ["home.servers.v1"]),
|
||||
createStore({ collapsed: {} as Record<string, boolean> }),
|
||||
)
|
||||
const [state] = createResource(
|
||||
() => ready.promise ?? Promise.resolve(),
|
||||
(p) => p.then(() => _state),
|
||||
{ initialValue: _state },
|
||||
)
|
||||
|
||||
return (
|
||||
<aside
|
||||
class="mt-6 flex min-h-0 min-w-0 flex-col gap-4 overflow-hidden lg:sticky lg:top-14 lg:mt-14 lg:h-[calc(100cqh-56px)] lg:self-start lg:pt-[52px]"
|
||||
aria-label={props.language.t("home.projects")}
|
||||
onWheel={(event) => {
|
||||
if (event.target === event.currentTarget) return
|
||||
props.onWheel(event)
|
||||
}}
|
||||
>
|
||||
<div class="flex h-7 min-w-0 shrink-0 items-center justify-between pl-1.5 pr-3">
|
||||
<div class="text-v2-text-text-muted [font-weight:530]">{props.language.t("home.projects")}</div>
|
||||
<Show
|
||||
when={global.servers.list().length === 1 && !(props.projects.length === 0 && props.recentlyClosed.length > 0)}
|
||||
>
|
||||
<TooltipV2 placement="bottom" value={props.language.t("home.project.add")}>
|
||||
<IconButtonV2
|
||||
data-action="home-add-project"
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
class="titlebar-icon [&_[data-slot=icon-svg]]:text-v2-icon-icon-muted"
|
||||
icon={<IconV2 name="folder-add-left" />}
|
||||
disabled={global.servers.health[ServerConnection.key(global.servers.list()[0]!)]?.healthy === false}
|
||||
onClick={() => props.chooseProject(global.servers.list()[0]!)}
|
||||
aria-label={props.language.t("home.project.add")}
|
||||
/>
|
||||
</TooltipV2>
|
||||
</Show>
|
||||
</div>
|
||||
<ScrollView data-slot="home-projects-scroll" class="min-h-0 min-w-0 shrink">
|
||||
<Show
|
||||
when={global.servers.list().length > 1}
|
||||
fallback={
|
||||
<div class="pr-3">
|
||||
<Show
|
||||
when={props.projects.length > 0}
|
||||
fallback={
|
||||
<HomeProjectEmpty
|
||||
server={global.servers.list()[0]!}
|
||||
recentlyClosed={props.recentlyClosed}
|
||||
homedir={props.homedir}
|
||||
chooseProject={props.chooseProject}
|
||||
openRecentProject={props.openRecentProject}
|
||||
language={props.language}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<HomeProjectList {...props} server={global.servers.list()[0]!} />
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="flex min-w-0 flex-col gap-4 pr-3">
|
||||
<For each={global.servers.list()}>
|
||||
{(item) => {
|
||||
const key = ServerConnection.key(item)
|
||||
const healthy = () => !!global.servers.health[key]?.healthy
|
||||
const serverCtx = global.ensureServerCtx(item)
|
||||
const projects = () => serverCtx.projects.list()
|
||||
const hasProjects = () => projects().length > 0
|
||||
const collapsed = () => !!state().collapsed[key]
|
||||
return (
|
||||
<div class="flex min-w-0 flex-col gap-1">
|
||||
<HomeServerRow
|
||||
server={item}
|
||||
selected={props.selected.server === key && !props.selected.directory}
|
||||
collapsed={collapsed()}
|
||||
health={global.servers.health[key]}
|
||||
controller={controller}
|
||||
focusServer={props.focusServer}
|
||||
chooseProject={props.chooseProject}
|
||||
openEdit={(server) => dialog.show(() => <DialogServerV2 mode="edit" server={server} />)}
|
||||
toggleCollapsed={() => setState("collapsed", key, !state().collapsed[key])}
|
||||
language={props.language}
|
||||
/>
|
||||
<Show when={healthy() && hasProjects() && !collapsed()}>
|
||||
<div class="mx-3 h-px bg-v2-border-border-base" />
|
||||
<HomeProjectList {...props} server={item} projects={projects()} />
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</ScrollView>
|
||||
<HomeUtilityNav
|
||||
class="mb-8 mt-4 hidden shrink-0 lg:flex"
|
||||
openSettings={props.openSettings}
|
||||
openHelp={props.openHelp}
|
||||
language={props.language}
|
||||
/>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeUtilityNav(props: {
|
||||
class?: string
|
||||
openSettings: () => void
|
||||
openHelp: () => void
|
||||
language: ReturnType<typeof useLanguage>
|
||||
}) {
|
||||
return (
|
||||
<div class={`${props.class ?? ""} min-w-0 flex-col gap-1 pr-3`}>
|
||||
<button
|
||||
type="button"
|
||||
class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
|
||||
onClick={props.openSettings}
|
||||
>
|
||||
<IconV2 name="settings-gear" size="small" />
|
||||
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("sidebar.settings")}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
|
||||
onClick={props.openHelp}
|
||||
>
|
||||
<IconV2 name="help" size="small" />
|
||||
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("sidebar.help")}</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeServerRow(props: {
|
||||
server: ServerConnection.Any
|
||||
selected: boolean
|
||||
collapsed: boolean
|
||||
health: ServerHealth | undefined
|
||||
controller: ReturnType<typeof useServerManagementController>
|
||||
focusServer: (server: ServerConnection.Any) => void
|
||||
chooseProject: (server: ServerConnection.Any) => void
|
||||
openEdit: (server: ServerConnection.Http) => void
|
||||
toggleCollapsed: () => void
|
||||
language: ReturnType<typeof useLanguage>
|
||||
}) {
|
||||
const global = useGlobal()
|
||||
const [state, setState] = createStore({ menuOpen: false })
|
||||
const healthy = () => !!props.health?.healthy
|
||||
const canToggle = () => healthy() && global.ensureServerCtx(props.server).projects.list().length > 0
|
||||
return (
|
||||
<div class="group/server relative flex h-7 min-w-0 items-center rounded-[6px]">
|
||||
<button
|
||||
type="button"
|
||||
class={`${HOME_PROJECT_NAV_ROW} pr-16 disabled:opacity-60`}
|
||||
data-selected={props.selected ? "" : undefined}
|
||||
disabled={!healthy()}
|
||||
onClick={() => props.focusServer(props.server)}
|
||||
>
|
||||
<span
|
||||
data-action="home-server-collapse"
|
||||
class="inline-flex -ml-0.5 -mr-1.5 size-5 shrink-0 items-center justify-center rounded-[4px] text-v2-icon-icon-muted"
|
||||
classList={{
|
||||
"hover:bg-v2-overlay-simple-overlay-hover": canToggle(),
|
||||
"cursor-default opacity-40": !canToggle(),
|
||||
}}
|
||||
aria-label={
|
||||
props.collapsed ? props.language.t("home.server.expand") : props.language.t("home.server.collapse")
|
||||
}
|
||||
aria-disabled={!canToggle()}
|
||||
aria-expanded={canToggle() ? !props.collapsed : undefined}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (!canToggle()) return
|
||||
props.toggleCollapsed()
|
||||
}}
|
||||
onPointerDown={(event) => event.preventDefault()}
|
||||
>
|
||||
<IconV2
|
||||
name="chevron-down"
|
||||
size="small"
|
||||
class="transition-transform duration-150 ease-in-out"
|
||||
style={{ transform: `rotate(${props.collapsed ? -90 : 0}deg)` }}
|
||||
/>
|
||||
</span>
|
||||
<div class="flex size-4 shrink-0 items-center justify-center -mr-0.5">
|
||||
<ServerHealthIndicator health={props.health} />
|
||||
</div>
|
||||
<span class="flex min-w-0 items-center gap-1">
|
||||
<span class={HOME_PROJECT_NAV_LABEL}>{props.server.displayName ?? new URL(props.server.http.url).host}</span>
|
||||
<Show when={props.server.label}>
|
||||
{(label) => (
|
||||
<span class="shrink-0 rounded-[3px] border border-v2-border-border-base px-1 py-0.5 text-[9px] leading-none text-v2-text-text-muted">
|
||||
{label()}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
class="hover-reveal absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-1 group-hover/server:opacity-100 focus-within:opacity-100 data-[menu=true]:opacity-100"
|
||||
data-menu={state.menuOpen}
|
||||
>
|
||||
<ServerRowMenu
|
||||
server={props.server}
|
||||
controller={props.controller}
|
||||
onEdit={props.openEdit}
|
||||
open={state.menuOpen}
|
||||
onOpenChange={(open) => setState("menuOpen", open)}
|
||||
/>
|
||||
<TooltipV2 class="flex shrink-0 items-center" placement="bottom" value={props.language.t("home.project.add")}>
|
||||
<IconButtonV2
|
||||
data-action="home-add-project"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
icon={<IconV2 name="folder-add-left" />}
|
||||
aria-label={props.language.t("home.project.add")}
|
||||
disabled={props.health?.healthy === false}
|
||||
onClick={() => props.chooseProject(props.server)}
|
||||
/>
|
||||
</TooltipV2>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type HomeProjectListProps = {
|
||||
server: ServerConnection.Any
|
||||
projects: LocalProject[]
|
||||
selected: HomeProjectSelection
|
||||
selectProject: (server: ServerConnection.Any, directory: string) => void
|
||||
openNewSession: (server: ServerConnection.Any, directory: string) => void
|
||||
editProject: (server: ServerConnection.Any, project: LocalProject) => void
|
||||
closeProject: (server: ServerConnection.Any, directory: string) => void
|
||||
clearNotifications: (server: ServerConnection.Any, project: LocalProject) => void
|
||||
unseenCount: (server: ServerConnection.Any, project: LocalProject) => number
|
||||
language: ReturnType<typeof useLanguage>
|
||||
}
|
||||
|
||||
function HomeProjectList(props: HomeProjectListProps) {
|
||||
const global = useGlobal()
|
||||
let listRef!: HTMLDivElement
|
||||
const projects = () => global.ensureServerCtx(props.server).projects
|
||||
|
||||
return (
|
||||
<DragDropProvider
|
||||
sensors={(defaults) => [
|
||||
...defaults.filter((sensor) => sensor !== PointerSensor),
|
||||
PointerSensor.configure({
|
||||
activationConstraints: (event) =>
|
||||
event.pointerType === "touch"
|
||||
? [new PointerActivationConstraints.Delay({ value: 250, tolerance: 5 })]
|
||||
: [new PointerActivationConstraints.Distance({ value: 4 })],
|
||||
preventActivation: (event) => event.target instanceof Element && !!event.target.closest("[data-action]"),
|
||||
}),
|
||||
]}
|
||||
modifiers={[RestrictToVerticalAxis, RestrictToElement.configure({ element: () => listRef })]}
|
||||
plugins={(defaults) => [
|
||||
...defaults.filter((plugin) => plugin !== AutoScroller && plugin !== Feedback),
|
||||
AutoScroller.configure({ acceleration: 8, threshold: { x: 0, y: 0.05 } }),
|
||||
Feedback.configure({ dropAnimation: null }),
|
||||
]}
|
||||
onDragEnd={(event) => {
|
||||
const source = event.operation.source
|
||||
if (event.canceled || !isSortable(source)) return
|
||||
if (source.initialIndex !== source.index) projects().move(source.id.toString(), source.index)
|
||||
if (props.selected.server !== ServerConnection.key(props.server))
|
||||
props.selectProject(props.server, source.id.toString())
|
||||
}}
|
||||
>
|
||||
<div class="flex min-w-0 flex-col gap-1" ref={listRef}>
|
||||
{/* Keyed on worktree strings: the enriched project objects are
|
||||
recreated on every store or sync update, so iterating them directly
|
||||
remounts all rows — killing any in-flight drag activation (the
|
||||
row's sortable unregisters on unmount) and discarding animations.
|
||||
String keys keep row elements alive and move them on reorder. */}
|
||||
<For each={props.projects.map((project) => project.worktree)}>
|
||||
{(worktree, index) => <HomeProjectSlot {...props} worktree={worktree} index={index} />}
|
||||
</For>
|
||||
</div>
|
||||
</DragDropProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeProjectSlot(
|
||||
props: HomeProjectListProps & {
|
||||
worktree: string
|
||||
index: () => number
|
||||
},
|
||||
) {
|
||||
const project = createMemo(() => props.projects.find((item) => item.worktree === props.worktree))
|
||||
|
||||
return (
|
||||
<Show when={project()}>
|
||||
{(item) => (
|
||||
<HomeProjectRow
|
||||
project={item()}
|
||||
server={props.server}
|
||||
index={props.index}
|
||||
serverSelected={props.selected.server === ServerConnection.key(props.server)}
|
||||
selected={
|
||||
props.selected.server === ServerConnection.key(props.server) && props.selected.directory === props.worktree
|
||||
}
|
||||
unseenCount={props.unseenCount(props.server, item())}
|
||||
selectProject={props.selectProject}
|
||||
openNewSession={props.openNewSession}
|
||||
editProject={props.editProject}
|
||||
closeProject={props.closeProject}
|
||||
clearNotifications={props.clearNotifications}
|
||||
language={props.language}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeProjectEmpty(props: {
|
||||
server: ServerConnection.Any
|
||||
recentlyClosed: LocalProject[]
|
||||
homedir: string
|
||||
chooseProject: (server: ServerConnection.Any) => void
|
||||
openRecentProject: (server: ServerConnection.Any, directory: string) => void
|
||||
language: ReturnType<typeof useLanguage>
|
||||
}) {
|
||||
const global = useGlobal()
|
||||
const unreachable = () => global.servers.health[ServerConnection.key(props.server)]?.healthy === false
|
||||
return (
|
||||
<div class="flex min-w-0 flex-col gap-1">
|
||||
<button
|
||||
type="button"
|
||||
data-action="home-add-project-row"
|
||||
class={`${HOME_PROJECT_NAV_ROW} disabled:opacity-60 [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
|
||||
disabled={unreachable()}
|
||||
onClick={() => props.chooseProject(props.server)}
|
||||
>
|
||||
<IconV2 name="folder-add-left" size="small" />
|
||||
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("home.project.add")}</span>
|
||||
</button>
|
||||
<Show when={props.recentlyClosed.length > 0}>
|
||||
<div class="mt-3 flex h-7 min-w-0 shrink-0 items-center pl-1.5 pr-3">
|
||||
<div class="text-v2-text-text-faint [font-weight:530]">{props.language.t("home.recentlyClosed")}</div>
|
||||
</div>
|
||||
<For each={props.recentlyClosed}>
|
||||
{(project) => (
|
||||
<HomeRecentlyClosedRow
|
||||
project={project}
|
||||
server={props.server}
|
||||
homedir={props.homedir}
|
||||
openRecentProject={props.openRecentProject}
|
||||
language={props.language}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeRecentlyClosedRow(props: {
|
||||
project: LocalProject
|
||||
server: ServerConnection.Any
|
||||
homedir: string
|
||||
openRecentProject: (server: ServerConnection.Any, directory: string) => void
|
||||
language: ReturnType<typeof useLanguage>
|
||||
}) {
|
||||
const global = useGlobal()
|
||||
const unreachable = () => global.servers.health[ServerConnection.key(props.server)]?.healthy === false
|
||||
const path = () => {
|
||||
const home = props.homedir
|
||||
const worktree = props.project.worktree
|
||||
if (home && (worktree === home || worktree.startsWith(`${home}/`))) return `~${worktree.slice(home.length)}`
|
||||
return worktree
|
||||
}
|
||||
return (
|
||||
<TooltipV2 placement="right" value={path()}>
|
||||
<button
|
||||
type="button"
|
||||
data-component="home-recently-closed-row"
|
||||
class={`${HOME_PROJECT_NAV_ROW} disabled:opacity-60`}
|
||||
disabled={unreachable()}
|
||||
onClick={() => props.openRecentProject(props.server, props.project.worktree)}
|
||||
>
|
||||
<HomeProjectAvatar project={props.project} outline />
|
||||
<span class={HOME_PROJECT_NAV_LABEL}>{displayName(props.project)}</span>
|
||||
</button>
|
||||
</TooltipV2>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeProjectRow(props: {
|
||||
project: LocalProject
|
||||
server: ServerConnection.Any
|
||||
index: () => number
|
||||
serverSelected: boolean
|
||||
selected: boolean
|
||||
unseenCount: number
|
||||
selectProject: (server: ServerConnection.Any, directory: string) => void
|
||||
openNewSession: (server: ServerConnection.Any, directory: string) => void
|
||||
editProject: (server: ServerConnection.Any, project: LocalProject) => void
|
||||
closeProject: (server: ServerConnection.Any, directory: string) => void
|
||||
clearNotifications: (server: ServerConnection.Any, project: LocalProject) => void
|
||||
language: ReturnType<typeof useLanguage>
|
||||
}) {
|
||||
const global = useGlobal()
|
||||
const platform = usePlatform()
|
||||
const serverUnreachable = () => global.servers.health[ServerConnection.key(props.server)]?.healthy === false
|
||||
const [state, setState] = createStore({ menuOpen: false })
|
||||
const sortable = useSortable({
|
||||
get id() {
|
||||
return props.project.worktree
|
||||
},
|
||||
get index() {
|
||||
return props.index()
|
||||
},
|
||||
})
|
||||
let pointerDownSelected: boolean | undefined
|
||||
const canRevealInFileManager = () =>
|
||||
platform.platform === "desktop" && !!platform.openPath && ServerConnection.local(props.server)
|
||||
const fileManagerActionLabel = () =>
|
||||
props.language.t(
|
||||
fileManagerApp(platform.platform === "desktop" ? (platform.os ?? "unknown") : "unknown").actionLabel,
|
||||
)
|
||||
const revealInFileManager = () => {
|
||||
if (!platform.openPath) return
|
||||
platform.openPath(props.project.worktree).catch((err: unknown) =>
|
||||
showToast({
|
||||
title: props.language.t("common.requestFailed"),
|
||||
description: errorMessage(err, props.language.t("common.requestFailed")),
|
||||
}),
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div
|
||||
ref={sortable.ref}
|
||||
class="group/project relative flex h-7 min-w-0 items-center rounded-[6px]"
|
||||
classList={{ "z-10": sortable.isDragSource() }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-component="home-project-row"
|
||||
class={`${HOME_PROJECT_NAV_ROW} pr-16 disabled:opacity-60`}
|
||||
classList={{
|
||||
"bg-v2-background-bg-layer-01 text-v2-text-text-base [box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)]":
|
||||
sortable.isDragSource(),
|
||||
}}
|
||||
data-selected={props.selected ? "" : undefined}
|
||||
aria-current={props.selected ? "page" : undefined}
|
||||
disabled={serverUnreachable()}
|
||||
onPointerDown={(event) => {
|
||||
// Same-server mouse selection happens on pointerdown (like tabs),
|
||||
// but only ever selects; selectProject toggles, and deselecting here
|
||||
// would fire on every drag before the threshold is met. Cross-server
|
||||
// selection waits for click so reordering a remote server's projects
|
||||
// does not focus that server and load its session index. Touch is
|
||||
// excluded so flick-scrolling the list cannot select rows.
|
||||
pointerDownSelected = undefined
|
||||
if (event.button !== 0 || event.pointerType === "touch") return
|
||||
if (!props.serverSelected) return
|
||||
pointerDownSelected = props.selected
|
||||
if (!props.selected) props.selectProject(props.server, props.project.worktree)
|
||||
}}
|
||||
onClick={(event) => {
|
||||
// The drag sensor calls preventDefault on post-drag clicks; never
|
||||
// toggle selection as part of a reorder.
|
||||
if (event.defaultPrevented) return
|
||||
// Keyboard activation and touch taps keep the original toggle.
|
||||
if (event.detail === 0 || pointerDownSelected === undefined) {
|
||||
props.selectProject(props.server, props.project.worktree)
|
||||
return
|
||||
}
|
||||
// Mouse: pointerdown already selected unselected rows; a plain click
|
||||
// on an already-selected row toggles it off.
|
||||
if (pointerDownSelected) props.selectProject(props.server, props.project.worktree)
|
||||
pointerDownSelected = undefined
|
||||
}}
|
||||
>
|
||||
<HomeProjectAvatar project={props.project} />
|
||||
<span class={HOME_PROJECT_NAV_LABEL}>{displayName(props.project)}</span>
|
||||
</button>
|
||||
<div
|
||||
class="hover-reveal absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-1 group-hover/project:opacity-100 focus-within:opacity-100 data-[menu=true]:opacity-100"
|
||||
data-menu={state.menuOpen}
|
||||
>
|
||||
<MenuV2
|
||||
gutter={6}
|
||||
modal={false}
|
||||
placement="bottom-end"
|
||||
open={state.menuOpen}
|
||||
onOpenChange={(open) => setState("menuOpen", open)}
|
||||
>
|
||||
<MenuV2.Trigger
|
||||
as={IconButtonV2}
|
||||
data-action="home-project-menu"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
icon={<IconV2 name="outline-dots" />}
|
||||
aria-label={props.language.t("common.moreOptions")}
|
||||
/>
|
||||
<MenuV2.Portal>
|
||||
<MenuV2.Content>
|
||||
<MenuV2.Item onSelect={() => props.openNewSession(props.server, props.project.worktree)}>
|
||||
{props.language.t("command.session.new")}
|
||||
</MenuV2.Item>
|
||||
<MenuV2.Item onSelect={() => props.editProject(props.server, props.project)}>
|
||||
{props.language.t("dialog.project.edit.title")}
|
||||
</MenuV2.Item>
|
||||
<Show when={canRevealInFileManager()}>
|
||||
<MenuV2.Item onSelect={revealInFileManager}>{fileManagerActionLabel()}</MenuV2.Item>
|
||||
</Show>
|
||||
<MenuV2.Item
|
||||
disabled={props.unseenCount === 0}
|
||||
onSelect={() => props.clearNotifications(props.server, props.project)}
|
||||
>
|
||||
{props.language.t("sidebar.project.clearNotifications")}
|
||||
</MenuV2.Item>
|
||||
<MenuV2.Separator />
|
||||
<MenuV2.Item onSelect={() => props.closeProject(props.server, props.project.worktree)}>
|
||||
{props.language.t("common.close")}
|
||||
</MenuV2.Item>
|
||||
</MenuV2.Content>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2>
|
||||
<IconButtonV2
|
||||
data-action="home-project-new-session"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
icon={<IconV2 name="edit" />}
|
||||
aria-label={props.language.t("command.session.new")}
|
||||
onClick={() => props.openNewSession(props.server, props.project.worktree)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeProjectAvatar(props: { project: LocalProject; outline?: boolean }) {
|
||||
const name = createMemo(() => displayName(props.project))
|
||||
return (
|
||||
<ProjectAvatar
|
||||
fallback={name()}
|
||||
src={props.outline ? undefined : getProjectAvatarSource(props.project.id, props.project.icon)}
|
||||
variant={props.outline ? "outline" : getProjectAvatarVariant(props.project.icon?.color)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeSessionLeading(props: {
|
||||
project: LocalProject
|
||||
session: Session
|
||||
server: ServerConnection.Key
|
||||
revealProjectOnHover: boolean
|
||||
}) {
|
||||
const tabs = useTabs()
|
||||
const hasOpenTab = createMemo(() => sessionHasOpenTab(tabs.store, props.server, props.session))
|
||||
return (
|
||||
<div class="relative shrink-0">
|
||||
<Show when={hasOpenTab()}>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute top-1/2 h-3 w-0.5 -translate-y-1/2 rounded-[2px] bg-v2-background-bg-layer-04"
|
||||
style={{ right: "calc(100% + 4px)" }}
|
||||
/>
|
||||
</Show>
|
||||
<SessionTabAvatar
|
||||
project={props.project}
|
||||
directory={props.session.directory}
|
||||
sessionId={props.session.id}
|
||||
server={props.server}
|
||||
revealProjectOnHover={props.revealProjectOnHover}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeSessionSearch(props: {
|
||||
value: string
|
||||
placeholder: string
|
||||
open: boolean
|
||||
loading: boolean
|
||||
results: HomeSessionRecord[]
|
||||
showProjectName: boolean
|
||||
server: ServerConnection.Key
|
||||
noResultsLabel: string
|
||||
bindFocus: (focus: () => void) => void
|
||||
onInput: (value: string) => void
|
||||
onFocus: () => void
|
||||
onClose: () => void
|
||||
onSelect: (session: Session, options?: OpenSessionOptions) => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const [store, setStore] = createStore({ active: "" })
|
||||
let root: HTMLDivElement | undefined
|
||||
let input: HTMLInputElement | undefined
|
||||
let listRef: HTMLDivElement | undefined
|
||||
|
||||
const focusInput = () => {
|
||||
input?.focus()
|
||||
props.onFocus()
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
props.bindFocus(focusInput)
|
||||
})
|
||||
|
||||
const syncActive = (results: HomeSessionRecord[]) => {
|
||||
if (results.length === 0) {
|
||||
setStore("active", "")
|
||||
return
|
||||
}
|
||||
if (!results.some((record) => homeSessionSearchKey(record) === store.active)) {
|
||||
setStore("active", homeSessionSearchKey(results[0]))
|
||||
}
|
||||
}
|
||||
|
||||
createEffect(() => syncActive(props.results))
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => props.value,
|
||||
() => syncActive(props.results),
|
||||
),
|
||||
)
|
||||
|
||||
const scrollActiveIntoView = () => {
|
||||
const key = store.active
|
||||
if (!key || !listRef) return
|
||||
const element = listRef.querySelector<HTMLElement>(`[data-key="${key}"]`)
|
||||
element?.scrollIntoView({ block: "nearest" })
|
||||
}
|
||||
|
||||
const moveActive = (delta: number) => {
|
||||
const results = props.results
|
||||
if (results.length === 0) return
|
||||
const index = results.findIndex((record) => homeSessionSearchKey(record) === store.active)
|
||||
const start = index === -1 ? 0 : index
|
||||
const next = (start + delta + results.length) % results.length
|
||||
setStore("active", homeSessionSearchKey(results[next]))
|
||||
scrollActiveIntoView()
|
||||
}
|
||||
|
||||
const selectActive = () => {
|
||||
const record = props.results.find((item) => homeSessionSearchKey(item) === store.active)
|
||||
if (!record) return
|
||||
props.onSelect(record.session)
|
||||
}
|
||||
|
||||
onCleanup(
|
||||
makeEventListener(document, "pointerdown", (event) => {
|
||||
if (!props.open) return
|
||||
const target = event.target
|
||||
if (!(target instanceof Node)) return
|
||||
if (root?.contains(target)) return
|
||||
props.onClose()
|
||||
}),
|
||||
)
|
||||
|
||||
return (
|
||||
<div class="w-full">
|
||||
<div ref={root} data-component="home-session-search" class="relative z-30 w-full">
|
||||
<Show when={props.open}>
|
||||
<div
|
||||
data-component="home-session-search-panel"
|
||||
class="absolute flex flex-col overflow-hidden rounded-[12px] bg-v2-background-bg-base shadow-[var(--v2-elevation-floating)]"
|
||||
style={{
|
||||
top: "-6px",
|
||||
left: "-6px",
|
||||
width: "calc(100% + 12px)",
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-col pt-9">
|
||||
<div id={HOME_SESSION_SEARCH_RESULTS_ID} role="listbox" class="flex flex-col gap-4 pt-4">
|
||||
<Show
|
||||
when={!props.loading}
|
||||
fallback={
|
||||
<div class="flex items-center justify-center px-4 py-3 text-v2-text-text-muted [font-weight:440]">
|
||||
<Spinner class="size-4" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={props.results.length > 0}
|
||||
fallback={
|
||||
<p class="my-1.5 px-4 pb-2 text-[13px] leading-4 tracking-[-0.04px] text-v2-text-text-muted [font-weight:440]">
|
||||
{props.noResultsLabel}
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<p class="my-1.5 pl-[18px] pr-6 text-[13px] leading-4 tracking-[-0.04px] text-v2-text-text-muted [font-weight:440]">
|
||||
{language.t("home.sessions.search.sessions")}
|
||||
</p>
|
||||
<ScrollView class="max-h-80" viewportRef={(el) => (listRef = el)}>
|
||||
<div class="flex flex-col gap-px pb-2">
|
||||
<For each={props.results}>
|
||||
{(record) => (
|
||||
<HomeSessionSearchResultRow
|
||||
record={record}
|
||||
showProjectName={props.showProjectName}
|
||||
server={props.server}
|
||||
selected={store.active === homeSessionSearchKey(record)}
|
||||
onHighlight={() => setStore("active", homeSessionSearchKey(record))}
|
||||
onSelect={(session, options) => props.onSelect(session, options)}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</ScrollView>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<label class="relative z-20 flex h-9 w-full items-center gap-2 rounded-[6px] bg-v2-background-bg-layer-02/60 py-1 pl-3 pr-2 text-v2-icon-icon-muted transition-[background-color,box-shadow] duration-[120ms] ease-in-out hover:bg-v2-background-bg-layer-02 focus-within:bg-v2-background-bg-layer-02">
|
||||
<IconV2 name="magnifying-glass" />
|
||||
<input
|
||||
ref={input}
|
||||
class="relative z-20 min-w-0 flex-1 border-0 bg-transparent text-v2-text-text-base outline-0 [font-weight:440] placeholder:text-v2-text-text-faint"
|
||||
value={props.value}
|
||||
placeholder={props.placeholder}
|
||||
aria-label={props.placeholder}
|
||||
aria-expanded={props.open}
|
||||
aria-controls={HOME_SESSION_SEARCH_RESULTS_ID}
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={
|
||||
store.active && props.open ? `home-session-search-option-${store.active}` : undefined
|
||||
}
|
||||
onFocus={() => props.onFocus()}
|
||||
onInput={(event) => props.onInput(event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
props.onClose()
|
||||
input?.blur()
|
||||
return
|
||||
}
|
||||
if (!props.open || props.results.length === 0) return
|
||||
if (event.altKey || event.metaKey) return
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault()
|
||||
moveActive(1)
|
||||
return
|
||||
}
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault()
|
||||
moveActive(-1)
|
||||
return
|
||||
}
|
||||
if (event.key === "Enter" && !event.isComposing) {
|
||||
event.preventDefault()
|
||||
selectActive()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Show when={props.value}>
|
||||
<IconButtonV2
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
class="relative z-20 shrink-0"
|
||||
icon={<IconV2 name="close" size="large" class="text-v2-icon-icon-muted" />}
|
||||
aria-label={props.placeholder}
|
||||
onClick={() => {
|
||||
props.onClose()
|
||||
input?.focus()
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeSessionSearchResultRow(props: {
|
||||
record: HomeSessionRecord
|
||||
showProjectName: boolean
|
||||
server: ServerConnection.Key
|
||||
selected: boolean
|
||||
onHighlight: () => void
|
||||
onSelect: (session: Session, options?: OpenSessionOptions) => void
|
||||
}) {
|
||||
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
|
||||
const showProjectName = () => props.showProjectName && props.record.projectName
|
||||
|
||||
const key = () => homeSessionSearchKey(props.record)
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
id={`home-session-search-option-${key()}`}
|
||||
data-key={key()}
|
||||
data-component="home-session-search-row"
|
||||
role="option"
|
||||
aria-selected={props.selected}
|
||||
classList={{
|
||||
[HOME_SEARCH_RESULT_ROW]: true,
|
||||
"bg-v2-overlay-simple-overlay-hover": props.selected,
|
||||
group: !!showProjectName(),
|
||||
}}
|
||||
onMouseEnter={() => props.onHighlight()}
|
||||
onMouseDown={(event) => {
|
||||
if (event.button === 1) event.preventDefault()
|
||||
}}
|
||||
onClick={(event) => props.onSelect(props.record.session, { background: isBackgroundOpen(event) })}
|
||||
onAuxClick={(event) => {
|
||||
if (!isBackgroundOpen(event)) return
|
||||
event.preventDefault()
|
||||
props.onSelect(props.record.session, { background: true })
|
||||
}}
|
||||
>
|
||||
<HomeSessionLeading
|
||||
project={props.record.project}
|
||||
session={props.record.session}
|
||||
server={props.server}
|
||||
revealProjectOnHover={!!showProjectName()}
|
||||
/>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
<span
|
||||
class={`${HOME_SEARCH_RESULT_TITLE} ${showProjectName() ? "max-w-[min(70%,480px)] flex-[0_1_auto]" : "flex-[1_1_auto]"}`}
|
||||
>
|
||||
{title()}
|
||||
</span>
|
||||
<Show when={showProjectName()}>
|
||||
<span class={HOME_SEARCH_RESULT_META}>{props.record.projectName}</span>
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeSessionGroupHeader(props: {
|
||||
title: string
|
||||
titleOpacity: number
|
||||
ref: ComponentProps<"div">["ref"]
|
||||
elevated?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
ref={props.ref}
|
||||
class={`pointer-events-none sticky top-[84px] lg:top-[108px] flex h-7 min-w-0 items-center justify-between pl-3 bg-v2-background-bg-base ${props.elevated ? "home-session-group-header z-[5]" : "z-10"}`}
|
||||
>
|
||||
<div class={HOME_SECTION_LABEL} style={{ opacity: props.titleOpacity }}>
|
||||
{props.title}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeSessionRow(props: {
|
||||
record: HomeSessionRecord
|
||||
showProjectName: boolean
|
||||
server: ServerConnection.Key
|
||||
openSession: (session: Session, options?: OpenSessionOptions) => void
|
||||
archiveSession: (session: Session) => Promise<void>
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
|
||||
const showProjectName = () => props.showProjectName && props.record.projectName
|
||||
|
||||
return (
|
||||
<div
|
||||
class="group/session relative flex h-10 min-w-0 items-center rounded-[6px]"
|
||||
classList={{ group: !!showProjectName() }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-component="home-session-row"
|
||||
class={`${HOME_ROW} h-10 min-w-0 flex-1 gap-2 py-3 pl-3 pr-10`}
|
||||
onMouseDown={(event) => {
|
||||
if (event.button === 1) event.preventDefault()
|
||||
}}
|
||||
onClick={(event) => props.openSession(props.record.session, { background: isBackgroundOpen(event) })}
|
||||
onAuxClick={(event) => {
|
||||
if (!isBackgroundOpen(event)) return
|
||||
event.preventDefault()
|
||||
props.openSession(props.record.session, { background: true })
|
||||
}}
|
||||
>
|
||||
<HomeSessionLeading
|
||||
project={props.record.project}
|
||||
session={props.record.session}
|
||||
server={props.server}
|
||||
revealProjectOnHover={!!showProjectName()}
|
||||
/>
|
||||
<span
|
||||
class={`min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-base [font-weight:530] ${showProjectName() ? "max-w-[min(70%,480px)] flex-[0_1_auto]" : "flex-[1_1_auto]"}`}
|
||||
>
|
||||
{title()}
|
||||
</span>
|
||||
<Show when={showProjectName()}>
|
||||
<span class="min-w-0 flex-[1_1_auto] overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-muted [font-weight:440]">
|
||||
{props.record.projectName}
|
||||
</span>
|
||||
</Show>
|
||||
</button>
|
||||
<Show when={SHOW_HOME_SESSION_ARCHIVE}>
|
||||
<div class="hover-reveal absolute right-1.5 top-1/2 flex -translate-y-1/2 items-center gap-1 group-hover/session:opacity-100 focus-within:opacity-100">
|
||||
<TooltipV2 class="flex shrink-0 items-center" placement="bottom" value={language.t("common.archive")}>
|
||||
<IconButtonV2
|
||||
data-action="home-session-archive"
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
icon={<IconV2 name="archive" />}
|
||||
aria-label={language.t("common.archive")}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
void props.archiveSession(props.record.session)
|
||||
}}
|
||||
/>
|
||||
</TooltipV2>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeSessionsEmpty(props: { onNewSession?: () => void }) {
|
||||
const language = useLanguage()
|
||||
return (
|
||||
<div class="flex min-h-full flex-col items-center gap-4 px-6 pt-[52px] text-center">
|
||||
<div class="shrink-0 text-[13px] leading-[13px] tracking-[-0.04px] text-v2-text-text-base [font-weight:530]">
|
||||
{language.t("home.sessions.empty")}
|
||||
</div>
|
||||
<p class="mb-1 text-center text-[13px] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-weight:440]">
|
||||
{language.t("home.sessions.empty.description")}
|
||||
</p>
|
||||
<Show when={props.onNewSession}>
|
||||
{(onNewSession) => (
|
||||
<ButtonV2 data-action="home-new-session" variant="neutral" size="normal" icon="edit" onClick={onNewSession()}>
|
||||
{language.t("command.session.new")}
|
||||
</ButtonV2>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeSessionSkeleton(props: { label: string }) {
|
||||
return (
|
||||
<div class="flex min-w-0 flex-col gap-4">
|
||||
<div class="flex h-7 min-w-0 items-center justify-between px-4">
|
||||
<div class={HOME_SECTION_LABEL}>{props.label}</div>
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-col gap-px" aria-hidden="true">
|
||||
<For each={[0, 1, 2, 3]}>{() => <div class="h-10 rounded-[6px] bg-v2-background-bg-deep opacity-70" />}</For>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function groupSessions(records: HomeSessionRecord[], language: ReturnType<typeof useLanguage>): HomeSessionGroup[] {
|
||||
const now = DateTime.local()
|
||||
const yesterday = now.minus({ days: 1 })
|
||||
const todaySessions = records.filter((record) =>
|
||||
DateTime.fromMillis(record.session.time.updated ?? record.session.time.created).hasSame(now, "day"),
|
||||
)
|
||||
const yesterdaySessions = records.filter((record) =>
|
||||
DateTime.fromMillis(record.session.time.updated ?? record.session.time.created).hasSame(yesterday, "day"),
|
||||
)
|
||||
const olderSessions = records.filter((record) => {
|
||||
const time = DateTime.fromMillis(record.session.time.updated ?? record.session.time.created)
|
||||
return !time.hasSame(now, "day") && !time.hasSame(yesterday, "day")
|
||||
})
|
||||
const olderTitle =
|
||||
todaySessions.length === 0 && yesterdaySessions.length === 0
|
||||
? language.t("sidebar.project.recentSessions")
|
||||
: language.t("home.sessions.group.older")
|
||||
|
||||
return [
|
||||
{ id: "today" as const, title: language.t("home.sessions.group.today"), sessions: todaySessions },
|
||||
{ id: "yesterday" as const, title: language.t("home.sessions.group.yesterday"), sessions: yesterdaySessions },
|
||||
{ id: "older" as const, title: olderTitle, sessions: olderSessions },
|
||||
].filter((group) => group.sessions.length > 0)
|
||||
}
|
||||
|
||||
export function LegacyHome() {
|
||||
const sync = useServerSync()
|
||||
const platform = usePlatform()
|
||||
const pickDirectory = useDirectoryPicker()
|
||||
const dialog = useDialog()
|
||||
const navigate = useNavigate()
|
||||
const global = useGlobal()
|
||||
const server = useServer()
|
||||
const language = useLanguage()
|
||||
const homedir = createMemo(() => sync().data.path.home)
|
||||
const serverUnreachable = createMemo(() => global.servers.health[server.key]?.healthy === false)
|
||||
const recent = createMemo(() => {
|
||||
return sync()
|
||||
.data.project.slice()
|
||||
.sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
|
||||
.slice(0, 5)
|
||||
})
|
||||
|
||||
const serverDotClass = createMemo(() => {
|
||||
const healthy = global.servers.health[server.key]?.healthy
|
||||
if (healthy === true) return "bg-icon-success-base"
|
||||
if (healthy === false) return "bg-icon-critical-base"
|
||||
return "bg-border-weak-base"
|
||||
})
|
||||
|
||||
function openProject(server: ServerConnection.Any, directory: string) {
|
||||
const serverCtx = global.ensureServerCtx(server)
|
||||
serverCtx.projects.open(directory)
|
||||
serverCtx.projects.touch(directory)
|
||||
navigate(`/${base64Encode(directory)}`)
|
||||
}
|
||||
|
||||
function chooseProject() {
|
||||
if (serverUnreachable()) return
|
||||
const s = server.current
|
||||
if (!s) return
|
||||
|
||||
const resolve = (result: string | string[] | null) => {
|
||||
if (Array.isArray(result)) {
|
||||
for (const directory of result) {
|
||||
openProject(s, directory)
|
||||
}
|
||||
} else if (result) {
|
||||
openProject(s, result)
|
||||
}
|
||||
}
|
||||
|
||||
pickDirectory({
|
||||
server: s,
|
||||
title: language.t("command.project.open"),
|
||||
multiple: true,
|
||||
onSelect: resolve,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="mx-auto mt-55 w-full md:w-auto px-4">
|
||||
<Logo class="md:w-xl opacity-12" />
|
||||
<Button
|
||||
size="large"
|
||||
variant="ghost"
|
||||
class="mt-4 mx-auto text-14-regular text-text-weak"
|
||||
onClick={() => dialog.show(() => <DialogSelectServer />)}
|
||||
thumbContainer={scroll.viewport.thumbTrack}
|
||||
thumbHoverTarget={scroll.viewport.hoverTarget}
|
||||
viewportRef={scroll.viewport.setViewport}
|
||||
onScroll={(event) => scroll.viewport.update(event.currentTarget.scrollTop)}
|
||||
onWheel={scroll.viewport.containOuterWheel}
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
"size-2 rounded-full": true,
|
||||
[serverDotClass()]: true,
|
||||
}}
|
||||
/>
|
||||
{server.name}
|
||||
</Button>
|
||||
<Switch>
|
||||
<Match when={sync().data.project.length > 0}>
|
||||
<div class="mt-20 w-full flex flex-col gap-4">
|
||||
<div class="flex gap-2 items-center justify-between pl-3">
|
||||
<div class="text-14-medium text-text-strong">{language.t("home.recentProjects")}</div>
|
||||
<Button
|
||||
icon="folder-add-left"
|
||||
size="normal"
|
||||
class="pl-2 pr-3"
|
||||
disabled={serverUnreachable()}
|
||||
onClick={chooseProject}
|
||||
>
|
||||
{language.t("command.project.open")}
|
||||
</Button>
|
||||
</div>
|
||||
<ul class="flex flex-col gap-2">
|
||||
<For each={recent()}>
|
||||
{(project) => (
|
||||
<Button
|
||||
size="large"
|
||||
variant="ghost"
|
||||
class="text-14-mono text-left justify-between px-3"
|
||||
onClick={() => openProject(server.current!, project.worktree)}
|
||||
>
|
||||
{project.worktree.replace(homedir(), "~")}
|
||||
<div class="text-14-regular text-text-weak">
|
||||
{DateTime.fromMillis(project.time.updated ?? project.time.created).toRelative()}
|
||||
</div>
|
||||
</Button>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={!sync().ready}>
|
||||
<div class="mt-30 mx-auto flex flex-col items-center gap-3">
|
||||
<div class="text-12-regular text-text-weak">{language.t("common.loading")}</div>
|
||||
<Button class="px-3" disabled={serverUnreachable()} onClick={chooseProject}>
|
||||
{language.t("command.project.open")}
|
||||
</Button>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<div class="mt-30 mx-auto flex flex-col items-center gap-3">
|
||||
<Icon name="folder-add-left" size="large" />
|
||||
<div class="flex flex-col gap-1 items-center justify-center">
|
||||
<div class="text-14-medium text-text-strong">{language.t("home.empty.title")}</div>
|
||||
<div class="text-12-regular text-text-weak">{language.t("home.empty.description")}</div>
|
||||
</div>
|
||||
<Button class="px-3 mt-1" disabled={serverUnreachable()} onClick={chooseProject}>
|
||||
{language.t("command.project.open")}
|
||||
</Button>
|
||||
</div>
|
||||
</Match>
|
||||
</Switch>
|
||||
class={`
|
||||
mx-auto grid min-h-full w-full max-w-[1080px] grid-rows-[auto_minmax(0,1fr)_auto] gap-4 px-3
|
||||
lg:grid-cols-[280px_minmax(0,720px)] lg:grid-rows-1 lg:gap-8 lg:px-6
|
||||
`}
|
||||
>
|
||||
<HomeProjects projects={projects} scroll={scroll} />
|
||||
<HomeSessions sessions={sessions} search={search} scroll={scroll} />
|
||||
<HomeUtilityNav
|
||||
class="flex lg:hidden"
|
||||
onOpenSettings={projects.utility.settings}
|
||||
onOpenHelp={projects.utility.help}
|
||||
language={projects.copy.language}
|
||||
/>
|
||||
</div>
|
||||
</ScrollView>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { type HomeProjectSelection, useLayout } from "@/context/layout"
|
||||
import { ServerConnection, useServer } from "@/context/server"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { toggleHomeProjectSelection } from "@/pages/layout/helpers"
|
||||
import { createEffect, createMemo } from "solid-js"
|
||||
|
||||
export function createHomeController() {
|
||||
const sync = useServerSync()
|
||||
const layout = useLayout()
|
||||
const server = useServer()
|
||||
const global = useGlobal()
|
||||
const tabs = useTabs()
|
||||
const selection = layout.home.selection
|
||||
const focusedServer = createMemo(
|
||||
() => global.servers.list().find((conn) => ServerConnection.key(conn) === selection().server) ?? server.current,
|
||||
)
|
||||
const focusedServerCtx = createMemo(() => {
|
||||
const conn = focusedServer()
|
||||
if (!conn) return undefined
|
||||
return global.ensureServerCtx(conn)
|
||||
})
|
||||
const focusedSync = () => focusedServerCtx()?.sync ?? sync()
|
||||
const projects = createMemo(() => focusedServerCtx()?.projects.list() ?? layout.projects.list())
|
||||
const recentlyClosed = createMemo(
|
||||
() => focusedServerCtx()?.projects.recentlyClosed() ?? layout.projects.recentlyClosed(),
|
||||
)
|
||||
const homedir = createMemo(() => focusedSync().data.path.home ?? "")
|
||||
const selectedProject = createMemo(() => projects().find((project) => project.worktree === selection().directory))
|
||||
const newSessionProject = createMemo(
|
||||
() =>
|
||||
selectedProject() ??
|
||||
projects().find((project) => project.worktree === focusedServerCtx()?.projects.last()) ??
|
||||
projects()[0],
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
const list = global.servers.list()
|
||||
if (list.some((conn) => ServerConnection.key(conn) === selection().server)) return
|
||||
const conn = list.find((conn) => ServerConnection.key(conn) === server.key) ?? list[0]
|
||||
if (conn) setSelection({ server: ServerConnection.key(conn) })
|
||||
})
|
||||
|
||||
function setSelection(next: HomeProjectSelection) {
|
||||
layout.home.setSelection(next)
|
||||
}
|
||||
|
||||
function openProjectNewSession(conn: ServerConnection.Any, directory: string) {
|
||||
const ctx = global.ensureServerCtx(conn)
|
||||
ctx.projects.open(directory)
|
||||
ctx.projects.touch(directory)
|
||||
void tabs.newDraft({ server: ServerConnection.key(conn), directory })
|
||||
}
|
||||
|
||||
return {
|
||||
selection: {
|
||||
value: selection,
|
||||
set: setSelection,
|
||||
focusServer: (conn: ServerConnection.Any) => setSelection({ server: ServerConnection.key(conn) }),
|
||||
},
|
||||
server: {
|
||||
list: global.servers.list,
|
||||
health: (conn: ServerConnection.Any) => global.servers.health[ServerConnection.key(conn)],
|
||||
context: (conn: ServerConnection.Any) => global.ensureServerCtx(conn),
|
||||
focused: focusedServer,
|
||||
focusedContext: focusedServerCtx,
|
||||
focusedSync,
|
||||
},
|
||||
project: {
|
||||
list: projects,
|
||||
recentlyClosed,
|
||||
homedir,
|
||||
selected: selectedProject,
|
||||
newSession: newSessionProject,
|
||||
forServer: (conn: ServerConnection.Any) => global.ensureServerCtx(conn).projects.list(),
|
||||
select: (conn: ServerConnection.Any, directory: string) => {
|
||||
const key = ServerConnection.key(conn)
|
||||
if (global.servers.health[key]?.healthy === false) return
|
||||
if (
|
||||
!global
|
||||
.ensureServerCtx(conn)
|
||||
.projects.list()
|
||||
.some((project) => project.worktree === directory)
|
||||
)
|
||||
return
|
||||
setSelection(toggleHomeProjectSelection(selection(), key, directory))
|
||||
},
|
||||
add: (conn: ServerConnection.Any, directories: string[]) => {
|
||||
const directory = directories[0]
|
||||
if (!directory) return
|
||||
const ctx = global.ensureServerCtx(conn)
|
||||
directories.forEach((item) => ctx.projects.open(item))
|
||||
ctx.projects.touch(directory)
|
||||
setSelection({ server: ServerConnection.key(conn), directory })
|
||||
},
|
||||
openNewSession: () => {
|
||||
const conn = focusedServer()
|
||||
const project = newSessionProject()
|
||||
if (!conn || !project) return
|
||||
openProjectNewSession(conn, project.worktree)
|
||||
},
|
||||
openProjectNewSession,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type HomeController = ReturnType<typeof createHomeController>
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useDirectoryPicker } from "@/components/directory-picker"
|
||||
import { useServerManagementController } from "@/components/dialog-select-server"
|
||||
import { useSettingsCommand } from "@/components/settings-dialog"
|
||||
import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2"
|
||||
import { type LocalProject } from "@/context/layout"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useNotification } from "@/context/notification"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import { closeHomeProject, errorMessage, homeProjectDirectories } from "@/pages/layout/helpers"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { createResource } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { HomeController } from "./home-controller"
|
||||
|
||||
export function createHomeProjectsController(home: HomeController) {
|
||||
const platform = usePlatform()
|
||||
const pickDirectory = useDirectoryPicker()
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const notification = useNotification()
|
||||
const openSettings = useSettingsCommand()
|
||||
const serverManagement = useServerManagementController({ navigateOnAdd: false })
|
||||
const [_state, setState, _, ready] = persisted(
|
||||
Persist.global("home.servers", ["home.servers.v1"]),
|
||||
createStore({ collapsed: {} as Record<string, boolean> }),
|
||||
)
|
||||
const [state] = createResource(
|
||||
() => ready.promise ?? Promise.resolve(),
|
||||
(promise) => promise.then(() => _state),
|
||||
{ initialValue: _state },
|
||||
)
|
||||
function directories(project: LocalProject) {
|
||||
return [project.worktree, ...(project.sandboxes ?? [])]
|
||||
}
|
||||
|
||||
function canRevealProject(conn: ServerConnection.Any) {
|
||||
return platform.platform === "desktop" && !!platform.openPath && ServerConnection.local(conn)
|
||||
}
|
||||
|
||||
return {
|
||||
copy: {
|
||||
language,
|
||||
},
|
||||
selection: {
|
||||
value: home.selection.value,
|
||||
},
|
||||
server: {
|
||||
list: home.server.list,
|
||||
health: home.server.health,
|
||||
projects: home.project.forServer,
|
||||
collapsed: (conn: ServerConnection.Any) => state().collapsed[ServerConnection.key(conn)] ?? false,
|
||||
toggleCollapsed: (conn: ServerConnection.Any) => {
|
||||
const key = ServerConnection.key(conn)
|
||||
setState("collapsed", key, !state().collapsed[key])
|
||||
},
|
||||
canDefault: serverManagement.canDefault,
|
||||
defaultKey: serverManagement.defaultKey,
|
||||
setDefault: (conn: ServerConnection.Any | undefined) =>
|
||||
serverManagement.setDefault(conn ? ServerConnection.key(conn) : null),
|
||||
remove: (conn: ServerConnection.Any) => serverManagement.handleRemove(ServerConnection.key(conn)),
|
||||
edit: (conn: ServerConnection.Http) => dialog.show(() => <DialogServerV2 mode="edit" server={conn} />),
|
||||
focus: home.selection.focusServer,
|
||||
},
|
||||
project: {
|
||||
list: home.project.list,
|
||||
recentlyClosed: home.project.recentlyClosed,
|
||||
homedir: home.project.homedir,
|
||||
select: home.project.select,
|
||||
add: home.project.add,
|
||||
openNewSession: home.project.openProjectNewSession,
|
||||
edit: (conn: ServerConnection.Any, project: LocalProject) => {
|
||||
void import("@/components/dialog-edit-project-v2").then(({ DialogEditProjectV2 }) => {
|
||||
void dialog.show(() => <DialogEditProjectV2 server={conn} project={project} />)
|
||||
})
|
||||
},
|
||||
unseenCount: (conn: ServerConnection.Any, project: LocalProject) => {
|
||||
const state = notification.ensureServerState(ServerConnection.key(conn))
|
||||
return directories(project).reduce((total, directory) => total + state.project.unseenCount(directory), 0)
|
||||
},
|
||||
clearNotifications: (conn: ServerConnection.Any, project: LocalProject) => {
|
||||
const state = notification.ensureServerState(ServerConnection.key(conn))
|
||||
directories(project)
|
||||
.filter((directory) => state.project.unseenCount(directory) > 0)
|
||||
.forEach((directory) => state.project.markViewed(directory))
|
||||
},
|
||||
choose: (conn: ServerConnection.Any) => {
|
||||
if (home.server.health(conn)?.healthy === false) return
|
||||
pickDirectory({
|
||||
server: conn,
|
||||
title: language.t("command.project.open"),
|
||||
multiple: true,
|
||||
onSelect: (result) => home.project.add(conn, homeProjectDirectories(result)),
|
||||
})
|
||||
},
|
||||
close: (conn: ServerConnection.Any, directory: string) => {
|
||||
const next = closeHomeProject(
|
||||
home.selection.value(),
|
||||
ServerConnection.key(conn),
|
||||
home.server.context(conn).projects,
|
||||
directory,
|
||||
)
|
||||
if (next) home.selection.set(next)
|
||||
},
|
||||
move: (conn: ServerConnection.Any, worktree: string, index: number) => {
|
||||
home.server.context(conn).projects.move(worktree, index)
|
||||
},
|
||||
canReveal: canRevealProject,
|
||||
reveal: (conn: ServerConnection.Any, project: LocalProject) => {
|
||||
if (!platform.openPath || !canRevealProject(conn)) return
|
||||
platform.openPath(project.worktree).catch((cause: unknown) =>
|
||||
showToast({
|
||||
title: language.t("common.requestFailed"),
|
||||
description: errorMessage(cause, language.t("common.requestFailed")),
|
||||
}),
|
||||
)
|
||||
},
|
||||
},
|
||||
utility: {
|
||||
settings: openSettings,
|
||||
help: () => platform.openLink("https://opencode.ai/desktop-feedback"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type HomeProjectsController = ReturnType<typeof createHomeProjectsController>
|
||||
@@ -0,0 +1,615 @@
|
||||
import { type Accessor, createMemo, For, type JSX, onCleanup, Show, splitProps } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
|
||||
import { isSortable, useSortable } from "@dnd-kit/solid/sortable"
|
||||
import { AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/dom"
|
||||
import { RestrictToVerticalAxis } from "@dnd-kit/abstract/modifiers"
|
||||
import { RestrictToElement } from "@dnd-kit/dom/modifiers"
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { getProjectAvatarVariant, type HomeProjectSelection, type LocalProject } from "@/context/layout"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
|
||||
import { ServerRowMenuView, serverMenuLabels } from "@/components/server/server-row-menu"
|
||||
import { ServerHealthIndicator } from "@/components/server/server-row"
|
||||
import { type ServerHealth } from "@/utils/server-health"
|
||||
import { fileManagerApp } from "@/utils/file-manager"
|
||||
|
||||
const HOME_PROJECT_NAV_LABEL = "min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
|
||||
const serverContextMenuID = (server: ServerConnection.Any) => `server:${ServerConnection.key(server)}`
|
||||
const projectContextMenuID = (server: ServerConnection.Any, directory: string) =>
|
||||
`project:${ServerConnection.key(server)}:${directory}`
|
||||
|
||||
export type HomeProjectsViewProps = {
|
||||
language: ReturnType<typeof useLanguage>
|
||||
servers: Accessor<ServerConnection.Any[]>
|
||||
projects: Accessor<LocalProject[]>
|
||||
recentlyClosed: Accessor<LocalProject[]>
|
||||
selection: Accessor<HomeProjectSelection>
|
||||
homedir: Accessor<string>
|
||||
serverHealth: (server: ServerConnection.Any) => ServerHealth | undefined
|
||||
projectsForServer: (server: ServerConnection.Any) => LocalProject[]
|
||||
collapsed: (server: ServerConnection.Any) => boolean
|
||||
canDefaultServer: Accessor<boolean>
|
||||
defaultServerKey: Accessor<ServerConnection.Key | null | undefined>
|
||||
canRevealProject: (server: ServerConnection.Any) => boolean
|
||||
unseenCount: (server: ServerConnection.Any, project: LocalProject) => number
|
||||
onWheel: (event: WheelEvent) => void
|
||||
onChooseProject: (server: ServerConnection.Any) => void
|
||||
onFocusServer: (server: ServerConnection.Any) => void
|
||||
onToggleCollapsed: (server: ServerConnection.Any) => void
|
||||
onEditServer: (server: ServerConnection.Http) => void
|
||||
onSetDefaultServer: (server: ServerConnection.Any | undefined) => void
|
||||
onRemoveServer: (server: ServerConnection.Any) => void
|
||||
onMoveProject: (server: ServerConnection.Any, worktree: string, index: number) => void
|
||||
onSelectProject: (server: ServerConnection.Any, directory: string) => void
|
||||
onAddProjects: (server: ServerConnection.Any, directories: string[]) => void
|
||||
onOpenProjectNewSession: (server: ServerConnection.Any, directory: string) => void
|
||||
onEditProject: (server: ServerConnection.Any, project: LocalProject) => void
|
||||
onRevealProject: (server: ServerConnection.Any, project: LocalProject) => void
|
||||
onClearNotifications: (server: ServerConnection.Any, project: LocalProject) => void
|
||||
onCloseProject: (server: ServerConnection.Any, directory: string) => void
|
||||
onOpenSettings: () => void
|
||||
onOpenHelp: () => void
|
||||
}
|
||||
|
||||
export function HomeProjectsView(props: HomeProjectsViewProps) {
|
||||
const [contextMenu, setContextMenu] = createStore({ open: undefined as string | undefined })
|
||||
const contextMenuProps = {
|
||||
contextMenuOpen: (id: string) => contextMenu.open === id,
|
||||
onSetContextMenuOpen: (id: string, open: boolean) => setContextMenu("open", open ? id : undefined),
|
||||
}
|
||||
return (
|
||||
<aside
|
||||
class={`
|
||||
mt-6 flex min-h-0 min-w-0 flex-col gap-4 overflow-hidden
|
||||
lg:sticky lg:top-14 lg:mt-14 lg:h-[calc(100cqh-56px)] lg:self-start lg:pt-[52px]
|
||||
`}
|
||||
aria-label={props.language.t("home.projects")}
|
||||
onWheel={(event) => {
|
||||
if (event.target === event.currentTarget) return
|
||||
props.onWheel(event)
|
||||
}}
|
||||
>
|
||||
<div class="flex h-7 min-w-0 shrink-0 items-center justify-between pl-1.5 pr-3">
|
||||
<div class="text-v2-text-text-muted [font-weight:530]">{props.language.t("home.projects")}</div>
|
||||
<Show
|
||||
when={props.servers().length === 1 && !(props.projects().length === 0 && props.recentlyClosed().length > 0)}
|
||||
>
|
||||
<TooltipV2 placement="bottom" value={props.language.t("home.project.add")}>
|
||||
<IconButtonV2
|
||||
data-action="home-add-project"
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
class="titlebar-icon [&_[data-slot=icon-svg]]:text-v2-icon-icon-muted"
|
||||
icon={<IconV2 name="folder-add-left" />}
|
||||
disabled={props.serverHealth(props.servers()[0])?.healthy === false}
|
||||
onClick={() => props.onChooseProject(props.servers()[0])}
|
||||
aria-label={props.language.t("home.project.add")}
|
||||
/>
|
||||
</TooltipV2>
|
||||
</Show>
|
||||
</div>
|
||||
<ScrollView data-slot="home-projects-scroll" class="min-h-0 min-w-0 shrink">
|
||||
<Show
|
||||
when={props.servers().length > 1}
|
||||
fallback={
|
||||
<div class="pr-3">
|
||||
<Show
|
||||
when={props.projects().length > 0}
|
||||
fallback={<HomeProjectEmpty {...props} server={props.servers()[0]} items={props.recentlyClosed()} />}
|
||||
>
|
||||
<HomeProjectList
|
||||
{...props}
|
||||
{...contextMenuProps}
|
||||
server={props.servers()[0]}
|
||||
items={props.projects()}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="flex min-w-0 flex-col gap-4 pr-3">
|
||||
<For each={props.servers()}>
|
||||
{(item) => {
|
||||
const projects = () => props.projectsForServer(item)
|
||||
const healthy = () => !!props.serverHealth(item)?.healthy
|
||||
const hasProjects = () => projects().length > 0
|
||||
const collapsed = () => props.collapsed(item)
|
||||
return (
|
||||
<div class="flex min-w-0 flex-col gap-1">
|
||||
<HomeServerRow
|
||||
server={item}
|
||||
{...props}
|
||||
{...contextMenuProps}
|
||||
selected={props.selection().server === ServerConnection.key(item) && !props.selection().directory}
|
||||
collapsed={collapsed()}
|
||||
health={props.serverHealth(item)}
|
||||
/>
|
||||
<Show when={healthy() && hasProjects() && !collapsed()}>
|
||||
<div class="mx-3 h-px bg-v2-border-border-base" />
|
||||
<HomeProjectList {...props} {...contextMenuProps} server={item} items={projects()} />
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</ScrollView>
|
||||
<HomeUtilityNav
|
||||
class="mb-8 mt-4 hidden shrink-0 lg:flex"
|
||||
onOpenSettings={props.onOpenSettings}
|
||||
onOpenHelp={props.onOpenHelp}
|
||||
language={props.language}
|
||||
/>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
export function HomeUtilityNav(props: {
|
||||
class?: string
|
||||
onOpenSettings: () => void
|
||||
onOpenHelp: () => void
|
||||
language: ReturnType<typeof useLanguage>
|
||||
}) {
|
||||
return (
|
||||
<div class={`${props.class ?? ""} min-w-0 flex-col gap-1 pr-3`}>
|
||||
<HomeProjectNavButton
|
||||
type="button"
|
||||
class="text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted"
|
||||
onClick={props.onOpenSettings}
|
||||
>
|
||||
<IconV2 name="settings-gear" size="small" />
|
||||
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("sidebar.settings")}</span>
|
||||
</HomeProjectNavButton>
|
||||
<HomeProjectNavButton
|
||||
type="button"
|
||||
class="text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted"
|
||||
onClick={props.onOpenHelp}
|
||||
>
|
||||
<IconV2 name="help" size="small" />
|
||||
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("sidebar.help")}</span>
|
||||
</HomeProjectNavButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeServerRow(props: {
|
||||
language: HomeProjectsViewProps["language"]
|
||||
projectsForServer: HomeProjectsViewProps["projectsForServer"]
|
||||
contextMenuOpen: HomeProjectsContextMenuProps["contextMenuOpen"]
|
||||
canDefaultServer: HomeProjectsViewProps["canDefaultServer"]
|
||||
defaultServerKey: HomeProjectsViewProps["defaultServerKey"]
|
||||
onFocusServer: HomeProjectsViewProps["onFocusServer"]
|
||||
onToggleCollapsed: HomeProjectsViewProps["onToggleCollapsed"]
|
||||
onEditServer: HomeProjectsViewProps["onEditServer"]
|
||||
onSetDefaultServer: HomeProjectsViewProps["onSetDefaultServer"]
|
||||
onRemoveServer: HomeProjectsViewProps["onRemoveServer"]
|
||||
onSetContextMenuOpen: HomeProjectsContextMenuProps["onSetContextMenuOpen"]
|
||||
onChooseProject: HomeProjectsViewProps["onChooseProject"]
|
||||
server: ServerConnection.Any
|
||||
selected: boolean
|
||||
collapsed: boolean
|
||||
health: ServerHealth | undefined
|
||||
}) {
|
||||
const healthy = () => !!props.health?.healthy
|
||||
const canToggle = () => healthy() && props.projectsForServer(props.server).length > 0
|
||||
const contextMenuID = () => serverContextMenuID(props.server)
|
||||
onCleanup(() => {
|
||||
const id = contextMenuID()
|
||||
if (props.contextMenuOpen(id)) props.onSetContextMenuOpen(id, false)
|
||||
})
|
||||
return (
|
||||
<div class="group/server relative flex h-7 min-w-0 items-center rounded-[6px]">
|
||||
<HomeProjectNavButton
|
||||
type="button"
|
||||
class="pr-16 disabled:opacity-60"
|
||||
data-selected={props.selected ? "" : undefined}
|
||||
disabled={!healthy()}
|
||||
onClick={() => props.onFocusServer(props.server)}
|
||||
>
|
||||
<span
|
||||
data-action="home-server-collapse"
|
||||
class={`
|
||||
-ml-0.5 -mr-1.5 inline-flex size-5 shrink-0 items-center justify-center
|
||||
rounded-[4px] text-v2-icon-icon-muted
|
||||
`}
|
||||
classList={{
|
||||
"hover:bg-v2-overlay-simple-overlay-hover": canToggle(),
|
||||
"cursor-default opacity-40": !canToggle(),
|
||||
}}
|
||||
aria-label={
|
||||
props.collapsed ? props.language.t("home.server.expand") : props.language.t("home.server.collapse")
|
||||
}
|
||||
aria-disabled={!canToggle()}
|
||||
aria-expanded={canToggle() ? !props.collapsed : undefined}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (!canToggle()) return
|
||||
props.onToggleCollapsed(props.server)
|
||||
}}
|
||||
onPointerDown={(event) => event.preventDefault()}
|
||||
>
|
||||
<IconV2
|
||||
name="chevron-down"
|
||||
size="small"
|
||||
class="transition-transform duration-150 ease-in-out"
|
||||
style={{ transform: `rotate(${props.collapsed ? -90 : 0}deg)` }}
|
||||
/>
|
||||
</span>
|
||||
<div class="flex size-4 shrink-0 items-center justify-center -mr-0.5">
|
||||
<ServerHealthIndicator health={props.health} />
|
||||
</div>
|
||||
<span class="flex min-w-0 items-center gap-1">
|
||||
<span class={HOME_PROJECT_NAV_LABEL}>{props.server.displayName ?? new URL(props.server.http.url).host}</span>
|
||||
<Show when={props.server.label}>
|
||||
{(label) => (
|
||||
<span
|
||||
class={`
|
||||
shrink-0 rounded-[3px] border border-v2-border-border-base px-1 py-0.5
|
||||
text-[9px] leading-none text-v2-text-text-muted
|
||||
`}
|
||||
>
|
||||
{label()}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
</span>
|
||||
</HomeProjectNavButton>
|
||||
<div
|
||||
class={`
|
||||
hover-reveal absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-1
|
||||
group-hover/server:opacity-100 focus-within:opacity-100 data-[menu=true]:opacity-100
|
||||
`}
|
||||
data-menu={props.contextMenuOpen(contextMenuID())}
|
||||
>
|
||||
<ServerRowMenuView
|
||||
server={props.server}
|
||||
labels={serverMenuLabels(props.language)}
|
||||
canDefault={props.canDefaultServer()}
|
||||
isDefault={props.defaultServerKey() === ServerConnection.key(props.server)}
|
||||
onEdit={props.onEditServer}
|
||||
onSetDefault={() => props.onSetDefaultServer(props.server)}
|
||||
onRemoveDefault={() => props.onSetDefaultServer(undefined)}
|
||||
onRemove={() => props.onRemoveServer(props.server)}
|
||||
open={props.contextMenuOpen(contextMenuID())}
|
||||
onOpenChange={(open) => props.onSetContextMenuOpen(contextMenuID(), open)}
|
||||
/>
|
||||
<TooltipV2 class="flex shrink-0 items-center" placement="bottom" value={props.language.t("home.project.add")}>
|
||||
<IconButtonV2
|
||||
data-action="home-add-project"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
icon={<IconV2 name="folder-add-left" />}
|
||||
aria-label={props.language.t("home.project.add")}
|
||||
disabled={props.health?.healthy === false}
|
||||
onClick={() => props.onChooseProject(props.server)}
|
||||
/>
|
||||
</TooltipV2>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type HomeProjectsContextMenuProps = {
|
||||
contextMenuOpen: (id: string) => boolean
|
||||
onSetContextMenuOpen: (id: string, open: boolean) => void
|
||||
}
|
||||
|
||||
type HomeProjectListProps = HomeProjectsViewProps &
|
||||
HomeProjectsContextMenuProps & {
|
||||
server: ServerConnection.Any
|
||||
items: LocalProject[]
|
||||
}
|
||||
|
||||
function HomeProjectList(props: HomeProjectListProps) {
|
||||
let listRef!: HTMLDivElement
|
||||
|
||||
return (
|
||||
<DragDropProvider
|
||||
sensors={(defaults) => [
|
||||
...defaults.filter((sensor) => sensor !== PointerSensor),
|
||||
PointerSensor.configure({
|
||||
activationConstraints: (event) =>
|
||||
event.pointerType === "touch"
|
||||
? [new PointerActivationConstraints.Delay({ value: 250, tolerance: 5 })]
|
||||
: [new PointerActivationConstraints.Distance({ value: 4 })],
|
||||
preventActivation: (event) => event.target instanceof Element && !!event.target.closest("[data-action]"),
|
||||
}),
|
||||
]}
|
||||
modifiers={[RestrictToVerticalAxis, RestrictToElement.configure({ element: () => listRef })]}
|
||||
plugins={(defaults) => [
|
||||
...defaults.filter((plugin) => plugin !== AutoScroller && plugin !== Feedback),
|
||||
AutoScroller.configure({ acceleration: 8, threshold: { x: 0, y: 0.05 } }),
|
||||
Feedback.configure({ dropAnimation: null }),
|
||||
]}
|
||||
onDragEnd={(event) => {
|
||||
const source = event.operation.source
|
||||
if (event.canceled || !isSortable(source)) return
|
||||
if (source.initialIndex !== source.index) props.onMoveProject(props.server, source.id.toString(), source.index)
|
||||
if (props.selection().server !== ServerConnection.key(props.server))
|
||||
props.onSelectProject(props.server, source.id.toString())
|
||||
}}
|
||||
>
|
||||
<div class="flex min-w-0 flex-col gap-1" ref={listRef}>
|
||||
{/* Keyed on worktree strings: the enriched project objects are
|
||||
recreated on every store or sync update, so iterating them directly
|
||||
remounts all rows — killing any in-flight drag activation (the
|
||||
row's sortable unregisters on unmount) and discarding animations.
|
||||
String keys keep row elements alive and move them on reorder. */}
|
||||
<For each={props.items.map((project) => project.worktree)}>
|
||||
{(worktree, index) => <HomeProjectSlot {...props} worktree={worktree} index={index} />}
|
||||
</For>
|
||||
</div>
|
||||
</DragDropProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeProjectSlot(
|
||||
props: HomeProjectListProps & {
|
||||
worktree: string
|
||||
index: () => number
|
||||
},
|
||||
) {
|
||||
const project = createMemo(() => props.items.find((item) => item.worktree === props.worktree))
|
||||
|
||||
return (
|
||||
<Show when={project()}>
|
||||
{(item) => (
|
||||
<HomeProjectRow
|
||||
{...props}
|
||||
project={item()}
|
||||
server={props.server}
|
||||
index={props.index}
|
||||
serverSelected={props.selection().server === ServerConnection.key(props.server)}
|
||||
selected={
|
||||
props.selection().server === ServerConnection.key(props.server) &&
|
||||
props.selection().directory === props.worktree
|
||||
}
|
||||
unseen={props.unseenCount(props.server, item())}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeProjectEmpty(
|
||||
props: HomeProjectsViewProps & {
|
||||
server: ServerConnection.Any
|
||||
items: LocalProject[]
|
||||
},
|
||||
) {
|
||||
const unreachable = () => props.serverHealth(props.server)?.healthy === false
|
||||
return (
|
||||
<div class="flex min-w-0 flex-col gap-1">
|
||||
<HomeProjectNavButton
|
||||
type="button"
|
||||
data-action="home-add-project-row"
|
||||
class="disabled:opacity-60 [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted"
|
||||
disabled={unreachable()}
|
||||
onClick={() => props.onChooseProject(props.server)}
|
||||
>
|
||||
<IconV2 name="folder-add-left" size="small" />
|
||||
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("home.project.add")}</span>
|
||||
</HomeProjectNavButton>
|
||||
<Show when={props.items.length > 0}>
|
||||
<div class="mt-3 flex h-7 min-w-0 shrink-0 items-center pl-1.5 pr-3">
|
||||
<div class="text-v2-text-text-faint [font-weight:530]">{props.language.t("home.recentlyClosed")}</div>
|
||||
</div>
|
||||
<For each={props.items}>
|
||||
{(project) => <HomeRecentlyClosedRow {...props} project={project} server={props.server} />}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeRecentlyClosedRow(
|
||||
props: HomeProjectsViewProps & {
|
||||
project: LocalProject
|
||||
server: ServerConnection.Any
|
||||
},
|
||||
) {
|
||||
const unreachable = () => props.serverHealth(props.server)?.healthy === false
|
||||
const path = () => {
|
||||
const home = props.homedir()
|
||||
const worktree = props.project.worktree
|
||||
if (home && (worktree === home || worktree.startsWith(`${home}/`))) return `~${worktree.slice(home.length)}`
|
||||
return worktree
|
||||
}
|
||||
return (
|
||||
<TooltipV2 placement="right" value={path()}>
|
||||
<HomeProjectNavButton
|
||||
type="button"
|
||||
data-component="home-recently-closed-row"
|
||||
class="disabled:opacity-60"
|
||||
disabled={unreachable()}
|
||||
onClick={() => props.onAddProjects(props.server, [props.project.worktree])}
|
||||
>
|
||||
<HomeProjectAvatar project={props.project} outline />
|
||||
<span class={HOME_PROJECT_NAV_LABEL}>{displayName(props.project)}</span>
|
||||
</HomeProjectNavButton>
|
||||
</TooltipV2>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeProjectRow(
|
||||
props: HomeProjectsViewProps &
|
||||
HomeProjectsContextMenuProps & {
|
||||
project: LocalProject
|
||||
server: ServerConnection.Any
|
||||
index: () => number
|
||||
serverSelected: boolean
|
||||
selected: boolean
|
||||
unseen: number
|
||||
},
|
||||
) {
|
||||
const platform = usePlatform()
|
||||
const serverUnreachable = () => props.serverHealth(props.server)?.healthy === false
|
||||
const sortable = useSortable({
|
||||
get id() {
|
||||
return props.project.worktree
|
||||
},
|
||||
get index() {
|
||||
return props.index()
|
||||
},
|
||||
})
|
||||
let pointerDownSelected: boolean | undefined
|
||||
const contextMenuID = () => projectContextMenuID(props.server, props.project.worktree)
|
||||
onCleanup(() => {
|
||||
const id = contextMenuID()
|
||||
if (props.contextMenuOpen(id)) props.onSetContextMenuOpen(id, false)
|
||||
})
|
||||
return (
|
||||
<div
|
||||
ref={sortable.ref}
|
||||
class="group/project relative flex h-7 min-w-0 items-center rounded-[6px]"
|
||||
classList={{ "z-10": sortable.isDragSource() }}
|
||||
>
|
||||
<HomeProjectNavButton
|
||||
type="button"
|
||||
data-component="home-project-row"
|
||||
class="pr-16 disabled:opacity-60"
|
||||
classList={{
|
||||
"bg-v2-background-bg-layer-01 text-v2-text-text-base": sortable.isDragSource(),
|
||||
"[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)]": sortable.isDragSource(),
|
||||
}}
|
||||
data-selected={props.selected ? "" : undefined}
|
||||
aria-current={props.selected ? "page" : undefined}
|
||||
disabled={serverUnreachable()}
|
||||
onPointerDown={(event) => {
|
||||
// Same-server mouse selection happens on pointerdown (like tabs),
|
||||
// but only ever selects; selectProject toggles, and deselecting here
|
||||
// would fire on every drag before the threshold is met. Cross-server
|
||||
// selection waits for click so reordering a remote server's projects
|
||||
// does not focus that server and load its session index. Touch is
|
||||
// excluded so flick-scrolling the list cannot select rows.
|
||||
pointerDownSelected = undefined
|
||||
if (event.button !== 0 || event.pointerType === "touch") return
|
||||
if (!props.serverSelected) return
|
||||
pointerDownSelected = props.selected
|
||||
if (!props.selected) props.onSelectProject(props.server, props.project.worktree)
|
||||
}}
|
||||
onClick={(event) => {
|
||||
// The drag sensor calls preventDefault on post-drag clicks; never
|
||||
// toggle selection as part of a reorder.
|
||||
if (event.defaultPrevented) return
|
||||
// Keyboard activation and touch taps keep the original toggle.
|
||||
if (event.detail === 0 || pointerDownSelected === undefined) {
|
||||
props.onSelectProject(props.server, props.project.worktree)
|
||||
return
|
||||
}
|
||||
// Mouse: pointerdown already selected unselected rows; a plain click
|
||||
// on an already-selected row toggles it off.
|
||||
if (pointerDownSelected) props.onSelectProject(props.server, props.project.worktree)
|
||||
pointerDownSelected = undefined
|
||||
}}
|
||||
>
|
||||
<HomeProjectAvatar project={props.project} />
|
||||
<span class={HOME_PROJECT_NAV_LABEL}>{displayName(props.project)}</span>
|
||||
</HomeProjectNavButton>
|
||||
<div
|
||||
class={`
|
||||
hover-reveal absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-1
|
||||
group-hover/project:opacity-100 focus-within:opacity-100 data-[menu=true]:opacity-100
|
||||
`}
|
||||
data-menu={props.contextMenuOpen(contextMenuID())}
|
||||
>
|
||||
<MenuV2
|
||||
gutter={6}
|
||||
modal={false}
|
||||
placement="bottom-end"
|
||||
open={props.contextMenuOpen(contextMenuID())}
|
||||
onOpenChange={(open) => props.onSetContextMenuOpen(contextMenuID(), open)}
|
||||
>
|
||||
<MenuV2.Trigger
|
||||
as={IconButtonV2}
|
||||
data-action="home-project-menu"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
icon={<IconV2 name="outline-dots" />}
|
||||
aria-label={props.language.t("common.moreOptions")}
|
||||
/>
|
||||
<MenuV2.Portal>
|
||||
<MenuV2.Content>
|
||||
<MenuV2.Item onSelect={() => props.onOpenProjectNewSession(props.server, props.project.worktree)}>
|
||||
{props.language.t("command.session.new")}
|
||||
</MenuV2.Item>
|
||||
<MenuV2.Item onSelect={() => props.onEditProject(props.server, props.project)}>
|
||||
{props.language.t("dialog.project.edit.title")}
|
||||
</MenuV2.Item>
|
||||
<Show when={props.canRevealProject(props.server)}>
|
||||
<MenuV2.Item onSelect={() => props.onRevealProject(props.server, props.project)}>
|
||||
{props.language.t(
|
||||
fileManagerApp(platform.platform === "desktop" ? (platform.os ?? "unknown") : "unknown")
|
||||
.actionLabel,
|
||||
)}
|
||||
</MenuV2.Item>
|
||||
</Show>
|
||||
<MenuV2.Item
|
||||
disabled={props.unseen === 0}
|
||||
onSelect={() => props.onClearNotifications(props.server, props.project)}
|
||||
>
|
||||
{props.language.t("sidebar.project.clearNotifications")}
|
||||
</MenuV2.Item>
|
||||
<MenuV2.Separator />
|
||||
<MenuV2.Item onSelect={() => props.onCloseProject(props.server, props.project.worktree)}>
|
||||
{props.language.t("common.close")}
|
||||
</MenuV2.Item>
|
||||
</MenuV2.Content>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2>
|
||||
<IconButtonV2
|
||||
data-action="home-project-new-session"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
icon={<IconV2 name="edit" />}
|
||||
aria-label={props.language.t("command.session.new")}
|
||||
onClick={() => props.onOpenProjectNewSession(props.server, props.project.worktree)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeProjectNavButton(props: JSX.ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||
const [local, rest] = splitProps(props, ["class", "classList", "children"])
|
||||
return (
|
||||
<button
|
||||
{...rest}
|
||||
class={`
|
||||
flex h-7 min-w-0 w-full shrink-0 cursor-default items-center gap-2 rounded-[6px] bg-transparent px-1.5 text-left
|
||||
text-v2-text-text-muted [font-weight:440] transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out
|
||||
hover:bg-v2-background-bg-layer-01 hover:text-v2-text-text-base hover:[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)]
|
||||
data-[selected]:bg-v2-background-bg-layer-03 data-[selected]:text-v2-text-text-base
|
||||
data-[selected]:[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)] data-[selected]:hover:bg-v2-background-bg-layer-03
|
||||
focus-visible:bg-v2-background-bg-layer-01 focus-visible:text-v2-text-text-base focus-visible:outline-none
|
||||
focus-visible:[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)]
|
||||
${local.class ?? ""}
|
||||
`}
|
||||
classList={local.classList}
|
||||
>
|
||||
{local.children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeProjectAvatar(props: { project: LocalProject; outline?: boolean }) {
|
||||
const name = createMemo(() => displayName(props.project))
|
||||
return (
|
||||
<ProjectAvatar
|
||||
fallback={name()}
|
||||
src={props.outline ? undefined : getProjectAvatarSource(props.project.id, props.project.icon)}
|
||||
variant={props.outline ? "outline" : getProjectAvatarVariant(props.project.icon?.color)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { HomeProjectsController } from "./home-projects-controller"
|
||||
import { HomeProjectsView } from "./home-projects-view"
|
||||
import type { HomeScrollController } from "./home-scroll-controller"
|
||||
|
||||
export function HomeProjects(props: { projects: HomeProjectsController; scroll: HomeScrollController }) {
|
||||
return (
|
||||
<HomeProjectsView
|
||||
language={props.projects.copy.language}
|
||||
servers={props.projects.server.list}
|
||||
projects={props.projects.project.list}
|
||||
recentlyClosed={props.projects.project.recentlyClosed}
|
||||
selection={props.projects.selection.value}
|
||||
homedir={props.projects.project.homedir}
|
||||
serverHealth={props.projects.server.health}
|
||||
projectsForServer={props.projects.server.projects}
|
||||
collapsed={props.projects.server.collapsed}
|
||||
canDefaultServer={props.projects.server.canDefault}
|
||||
defaultServerKey={props.projects.server.defaultKey}
|
||||
canRevealProject={props.projects.project.canReveal}
|
||||
unseenCount={props.projects.project.unseenCount}
|
||||
onWheel={props.scroll.viewport.containWheel}
|
||||
onChooseProject={props.projects.project.choose}
|
||||
onFocusServer={props.projects.server.focus}
|
||||
onToggleCollapsed={props.projects.server.toggleCollapsed}
|
||||
onEditServer={props.projects.server.edit}
|
||||
onSetDefaultServer={props.projects.server.setDefault}
|
||||
onRemoveServer={props.projects.server.remove}
|
||||
onMoveProject={props.projects.project.move}
|
||||
onSelectProject={props.projects.project.select}
|
||||
onAddProjects={props.projects.project.add}
|
||||
onOpenProjectNewSession={props.projects.project.openNewSession}
|
||||
onEditProject={props.projects.project.edit}
|
||||
onRevealProject={props.projects.project.reveal}
|
||||
onClearNotifications={props.projects.project.clearNotifications}
|
||||
onCloseProject={props.projects.project.close}
|
||||
onOpenSettings={props.projects.utility.settings}
|
||||
onOpenHelp={props.projects.utility.help}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { createEffect, createSignal, onCleanup, type Accessor } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { HomeSessionGroup } from "./home-sessions-controller"
|
||||
|
||||
const HOME_SESSION_HEADER_STICKY_TOP = 12
|
||||
const HOME_SESSION_HEADER_TEXT_HEIGHT = 16
|
||||
const HOME_SESSION_HEADER_FADE_DISTANCE = 16
|
||||
|
||||
export function createHomeScrollController(groups: Accessor<HomeSessionGroup[]>) {
|
||||
const [thumbTrack, setThumbTrack] = createSignal<HTMLDivElement>()
|
||||
const [hoverTarget, setHoverTarget] = createSignal<HTMLElement>()
|
||||
const [state, setState] = createStore({
|
||||
titleOpacity: {} as Partial<Record<HomeSessionGroup["id"], number>>,
|
||||
})
|
||||
const headerRefs = new Map<HomeSessionGroup["id"], HTMLDivElement>()
|
||||
const headerOffsets = new Map<HomeSessionGroup["id"], number>()
|
||||
let viewport: HTMLDivElement | undefined
|
||||
let content: HTMLDivElement | undefined
|
||||
let positionFrame: number | undefined
|
||||
let resizeObserver: ResizeObserver | undefined
|
||||
let stickyTop = HOME_SESSION_HEADER_STICKY_TOP
|
||||
|
||||
createEffect(() => {
|
||||
const items = groups()
|
||||
const ids = new Set(items.map((group) => group.id))
|
||||
headerRefs.forEach((_, id) => {
|
||||
if (!ids.has(id)) headerRefs.delete(id)
|
||||
})
|
||||
headerOffsets.forEach((_, id) => {
|
||||
if (!ids.has(id)) headerOffsets.delete(id)
|
||||
})
|
||||
if (items.length === 0) {
|
||||
content = undefined
|
||||
bindResizeObserver()
|
||||
}
|
||||
queuePositionUpdate()
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (positionFrame !== undefined) cancelAnimationFrame(positionFrame)
|
||||
resizeObserver?.disconnect()
|
||||
})
|
||||
|
||||
function queuePositionUpdate() {
|
||||
if (typeof requestAnimationFrame === "undefined") {
|
||||
updatePositionCache()
|
||||
return
|
||||
}
|
||||
if (positionFrame !== undefined) return
|
||||
positionFrame = requestAnimationFrame(() => {
|
||||
positionFrame = undefined
|
||||
updatePositionCache()
|
||||
})
|
||||
}
|
||||
|
||||
function updatePositionCache() {
|
||||
if (!viewport) return
|
||||
const header = groups()
|
||||
.map((group) => headerRefs.get(group.id))
|
||||
.find((element) => element !== undefined)
|
||||
if (header && typeof getComputedStyle === "function") {
|
||||
const top = Number.parseFloat(getComputedStyle(header).top)
|
||||
if (Number.isFinite(top)) stickyTop = top
|
||||
}
|
||||
groups().forEach((group) => {
|
||||
const element = headerRefs.get(group.id)
|
||||
if (element) headerOffsets.set(group.id, element.offsetTop)
|
||||
})
|
||||
update(viewport.scrollTop)
|
||||
}
|
||||
|
||||
function update(scrollTop: number) {
|
||||
const items = groups()
|
||||
items.forEach((group, index) => {
|
||||
const nextOffset = items
|
||||
.slice(index + 1)
|
||||
.map((item) => headerOffsets.get(item.id))
|
||||
.find((offset) => offset !== undefined)
|
||||
const fadeEnd = stickyTop + HOME_SESSION_HEADER_TEXT_HEIGHT
|
||||
const nextTop = nextOffset === undefined ? undefined : nextOffset - scrollTop
|
||||
const opacity =
|
||||
nextTop === undefined ? 1 : Math.max(0, Math.min(1, (nextTop - fadeEnd) / HOME_SESSION_HEADER_FADE_DISTANCE))
|
||||
setState("titleOpacity", group.id, Math.round(opacity * 1000) / 1000)
|
||||
})
|
||||
}
|
||||
|
||||
function bindResizeObserver() {
|
||||
resizeObserver?.disconnect()
|
||||
if (typeof ResizeObserver === "undefined") return
|
||||
resizeObserver = new ResizeObserver(queuePositionUpdate)
|
||||
if (viewport) resizeObserver.observe(viewport)
|
||||
if (content) resizeObserver.observe(content)
|
||||
}
|
||||
|
||||
function containWheel(event: WheelEvent) {
|
||||
if (!viewport) return
|
||||
if (event.defaultPrevented || event.ctrlKey || !event.deltaY) return
|
||||
if (!(event.target instanceof Element)) return
|
||||
const scrollable = event.target.closest<HTMLElement>("[data-scrollable]")
|
||||
if (
|
||||
scrollable !== viewport &&
|
||||
scrollable &&
|
||||
(event.deltaY < 0
|
||||
? scrollable.scrollTop > 0
|
||||
: scrollable.scrollTop < scrollable.scrollHeight - scrollable.clientHeight)
|
||||
)
|
||||
return
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
return {
|
||||
viewport: {
|
||||
thumbTrack,
|
||||
hoverTarget,
|
||||
setThumbTrack,
|
||||
setHoverTarget,
|
||||
setViewport: (element: HTMLDivElement) => {
|
||||
viewport = element
|
||||
bindResizeObserver()
|
||||
queuePositionUpdate()
|
||||
},
|
||||
update,
|
||||
containWheel,
|
||||
containOuterWheel: (event: WheelEvent) => {
|
||||
if (!viewport) return
|
||||
if (event.target instanceof Node && viewport.contains(event.target)) return
|
||||
containWheel(event)
|
||||
},
|
||||
},
|
||||
header: {
|
||||
setContent: (element: HTMLDivElement) => {
|
||||
content = element
|
||||
bindResizeObserver()
|
||||
queuePositionUpdate()
|
||||
},
|
||||
setHeader: (id: HomeSessionGroup["id"], element: HTMLDivElement) => {
|
||||
headerRefs.set(id, element)
|
||||
queuePositionUpdate()
|
||||
},
|
||||
titleOpacity: (id: HomeSessionGroup["id"]) => state.titleOpacity[id] ?? 1,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type HomeScrollController = ReturnType<typeof createHomeScrollController>
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useCommand } from "@/context/command"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { serverName } from "@/context/server"
|
||||
import { displayName } from "@/pages/layout/helpers"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { createMemo, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { HomeController } from "./home-controller"
|
||||
import { homeSessionSearchKey, type HomeSessionRecord, type HomeSessionsController } from "./home-sessions-controller"
|
||||
|
||||
type HomeSessionSearchSource = Pick<HomeSessionsController, "data" | "session">
|
||||
|
||||
export function createHomeSessionSearchController(home: HomeController, sessions: HomeSessionSearchSource) {
|
||||
const command = useCommand()
|
||||
const language = useLanguage()
|
||||
const [state, setState] = createStore({ value: "", focused: false, highlighted: "" })
|
||||
let root: HTMLDivElement | undefined
|
||||
let input: HTMLInputElement | undefined
|
||||
let list: HTMLDivElement | undefined
|
||||
const query = createMemo(() => state.value.trim())
|
||||
const results = createMemo(() => {
|
||||
const value = query().toLowerCase()
|
||||
if (!value) return []
|
||||
return sessions.data
|
||||
.searchRecords()
|
||||
.filter((record) => `${record.session.title} ${record.projectName}`.toLowerCase().includes(value))
|
||||
})
|
||||
const active = createMemo(() => {
|
||||
const records = results()
|
||||
if (records.some((record) => homeSessionSearchKey(record) === state.highlighted)) return state.highlighted
|
||||
return records[0] ? homeSessionSearchKey(records[0]) : ""
|
||||
})
|
||||
const open = createMemo(() => state.focused && query().length > 0)
|
||||
const placeholder = createMemo(() => {
|
||||
const project = home.project.selected()
|
||||
if (project) return language.t("home.sessions.search.placeholder.scoped", { scope: displayName(project) })
|
||||
if (home.server.list().length > 1) {
|
||||
const conn = home.server.focused()
|
||||
if (conn) return language.t("home.sessions.search.placeholder.scoped", { scope: serverName(conn) })
|
||||
}
|
||||
return language.t("home.sessions.search.placeholder")
|
||||
})
|
||||
|
||||
onCleanup(
|
||||
makeEventListener(document, "pointerdown", (event) => {
|
||||
if (!open()) return
|
||||
const target = event.target
|
||||
if (!(target instanceof Node) || root?.contains(target)) return
|
||||
close()
|
||||
}),
|
||||
)
|
||||
|
||||
command.register("home.search", () => [
|
||||
{
|
||||
id: "home.sessions.search.focus",
|
||||
title: placeholder(),
|
||||
keybind: "mod+f",
|
||||
hidden: true,
|
||||
onSelect: focus,
|
||||
},
|
||||
])
|
||||
|
||||
function focus() {
|
||||
input?.focus()
|
||||
setState("focused", true)
|
||||
}
|
||||
|
||||
function close() {
|
||||
setState({ value: "", focused: false })
|
||||
}
|
||||
|
||||
function select(record: HomeSessionRecord, options?: { background?: boolean }) {
|
||||
sessions.session.open(record.session, options)
|
||||
if (!options?.background) close()
|
||||
}
|
||||
|
||||
return {
|
||||
query: {
|
||||
value: () => state.value,
|
||||
placeholder,
|
||||
open,
|
||||
focus,
|
||||
input: (value: string) => setState({ value, highlighted: "" }),
|
||||
close,
|
||||
},
|
||||
result: {
|
||||
loading: sessions.data.loading,
|
||||
list: results,
|
||||
active,
|
||||
noResultsLabel: () => language.t("home.sessions.search.noResults", { query: query() }),
|
||||
highlight: (record: HomeSessionRecord) => setState("highlighted", homeSessionSearchKey(record)),
|
||||
move: (delta: number) => {
|
||||
const records = results()
|
||||
if (records.length === 0) return
|
||||
const index = records.findIndex((record) => homeSessionSearchKey(record) === active())
|
||||
const next = ((index === -1 ? 0 : index) + delta + records.length) % records.length
|
||||
setState("highlighted", homeSessionSearchKey(records[next]))
|
||||
list?.querySelector<HTMLElement>(`[data-key="${state.highlighted}"]`)?.scrollIntoView({ block: "nearest" })
|
||||
},
|
||||
select,
|
||||
selectActive: () => {
|
||||
const record = results().find((item) => homeSessionSearchKey(item) === active())
|
||||
if (record) select(record)
|
||||
},
|
||||
},
|
||||
element: {
|
||||
setRoot: (element: HTMLDivElement) => (root = element),
|
||||
setInput: (element: HTMLInputElement) => (input = element),
|
||||
setList: (element: HTMLDivElement) => (list = element),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type HomeSessionSearchController = ReturnType<typeof createHomeSessionSearchController>
|
||||
@@ -0,0 +1,314 @@
|
||||
import type { Session } from "@opencode-ai/sdk/v2/client"
|
||||
import { preloadMarkdown } from "@opencode-ai/session-ui/markdown-cache"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useMarked } from "@opencode-ai/ui/context/marked"
|
||||
import { useQuery } from "@tanstack/solid-query"
|
||||
import { DateTime } from "luxon"
|
||||
import { type Accessor, createEffect, createMemo, createRoot, type JSX, startTransition } from "solid-js"
|
||||
import { produce } from "solid-js/store"
|
||||
import { useCommand } from "@/context/command"
|
||||
import {
|
||||
loadHomeSessionIndex,
|
||||
retainHomeSessions,
|
||||
type HomeSessionEvents,
|
||||
} from "@/context/global-sync/home-session-index"
|
||||
import type { LocalProject } from "@/context/layout"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import { sessionHasOpenTab, useTabs } from "@/context/tabs"
|
||||
import { displayName, errorMessage, projectForSession } from "@/pages/layout/helpers"
|
||||
import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { Binary } from "@opencode-ai/core/util/binary"
|
||||
import { archiveHomeSession } from "../home-session-archive"
|
||||
import type { HomeController } from "./home-controller"
|
||||
|
||||
const HOME_SESSION_LIMIT = 64
|
||||
export type HomeSessionRecord = {
|
||||
session: Session
|
||||
project: LocalProject
|
||||
projectName: string
|
||||
}
|
||||
|
||||
export type HomeSessionGroup = {
|
||||
id: "today" | "yesterday" | "older"
|
||||
title: string
|
||||
sessions: HomeSessionRecord[]
|
||||
}
|
||||
|
||||
export type OpenSessionOptions = { background?: boolean }
|
||||
|
||||
export function createHomeSessionsController(home: HomeController) {
|
||||
const tabs = useTabs()
|
||||
const command = useCommand()
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const marked = useMarked()
|
||||
const projectDirectories = createMemo(() => {
|
||||
const project = home.project.selected()
|
||||
if (!project) return home.project.list().flatMap(directories)
|
||||
return directories(project)
|
||||
})
|
||||
const projectByID = createMemo(
|
||||
() => new Map(home.project.list().flatMap((project) => (project.id ? [[project.id, project] as const] : []))),
|
||||
)
|
||||
const homeSessions = () => home.server.focusedSync().homeSessions
|
||||
const sessionEventLoad = useQuery(() => ({
|
||||
queryKey: homeSessions().eventsKey,
|
||||
queryFn: async (): Promise<HomeSessionEvents> => ({ sequence: 0, entries: [] }),
|
||||
initialData: { sequence: 0, entries: [] } satisfies HomeSessionEvents,
|
||||
enabled: false,
|
||||
}))
|
||||
const sessionLoad = useQuery(() => ({
|
||||
queryKey: homeSessions().indexKey,
|
||||
enabled: !!home.server.focusedContext(),
|
||||
queryFn: async ({ signal }) => {
|
||||
const ctx = home.server.focusedContext()
|
||||
if (!ctx) return { sessions: [], eventSequence: 0 }
|
||||
const cache = homeSessions()
|
||||
const eventSequence = cache.eventSequence()
|
||||
const index = await loadHomeSessionIndex(
|
||||
(input, options) => ctx.sdk.client.v2.session.list(input, options),
|
||||
eventSequence,
|
||||
signal,
|
||||
)
|
||||
cache.complete(eventSequence)
|
||||
return index
|
||||
},
|
||||
retry: false,
|
||||
staleTime: 30_000,
|
||||
refetchOnMount: true,
|
||||
refetchOnReconnect: true,
|
||||
}))
|
||||
const indexedSessions = createMemo(() =>
|
||||
retainHomeSessions(
|
||||
homeSessions().sessions(sessionLoad.data, sessionEventLoad.data),
|
||||
HOME_SESSION_LIMIT,
|
||||
Date.now(),
|
||||
),
|
||||
)
|
||||
const allRecords = createMemo(() =>
|
||||
buildHomeSessionRecords({
|
||||
sessions: indexedSessions,
|
||||
projectDirectories,
|
||||
projects: home.project.list,
|
||||
projectByID,
|
||||
}),
|
||||
)
|
||||
const records = createMemo(() => allRecords().slice(0, HOME_SESSION_LIMIT))
|
||||
const groups = createMemo(() => groupSessions(records(), language))
|
||||
const prefetched = new Set<string>()
|
||||
|
||||
createEffect(() => {
|
||||
const ctx = home.server.focusedContext()
|
||||
const conn = home.server.focused()
|
||||
if (!ctx || !conn) return
|
||||
records()
|
||||
.slice(0, 2)
|
||||
.forEach((record) => {
|
||||
const key = `${ServerConnection.key(conn)}\0${record.session.id}`
|
||||
if (prefetched.has(key)) return
|
||||
prefetched.add(key)
|
||||
createRoot((dispose) => {
|
||||
try {
|
||||
void ctx.sync.session
|
||||
.sync(record.session.id)
|
||||
.then(() =>
|
||||
Promise.all(
|
||||
(ctx.sync.session.data.message[record.session.id] ?? []).flatMap((message) =>
|
||||
(ctx.sync.session.data.part[message.id] ?? []).flatMap((part) => {
|
||||
if (part.type !== "text" || !part.text) return []
|
||||
return preloadMarkdown(part.text, part.id, marked)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
.catch(() => {})
|
||||
.finally(dispose)
|
||||
} catch {
|
||||
dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
command.register("home.palette", () => [
|
||||
{
|
||||
id: "command.palette",
|
||||
title: language.t("command.palette"),
|
||||
hidden: true,
|
||||
onSelect: async () => {
|
||||
const conn = home.server.focused()
|
||||
if (!conn) return
|
||||
const ctx = home.server.focusedContext()
|
||||
if (!ctx) return
|
||||
const { DialogHomeCommandPaletteV2 } = await import("@/components/dialog-command-palette-v2")
|
||||
void dialog.show(() => (
|
||||
<DialogHomeCommandPaletteV2
|
||||
server={conn}
|
||||
onSelectSession={(entry) => {
|
||||
if (!entry.sessionID || !entry.directory || !entry.server) return
|
||||
const sessionID = entry.sessionID
|
||||
const server = entry.server
|
||||
const directory = entry.project?.worktree ?? entry.directory
|
||||
ctx.projects.open(directory)
|
||||
ctx.projects.touch(directory)
|
||||
void startTransition(() => {
|
||||
const tab = tabs.addSessionTab({ server, sessionId: sessionID })
|
||||
tabs.select(tab)
|
||||
})
|
||||
}}
|
||||
/>
|
||||
))
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
return {
|
||||
copy: {
|
||||
language,
|
||||
},
|
||||
data: {
|
||||
records,
|
||||
groups,
|
||||
loading: () => sessionLoad.isLoading,
|
||||
searchRecords: allRecords,
|
||||
},
|
||||
session: {
|
||||
showProjectName: () => !home.project.selected(),
|
||||
server: () => home.selection.value().server,
|
||||
canCreate: () => !!home.project.newSession(),
|
||||
create: home.project.openNewSession,
|
||||
open: (session: Session, options?: OpenSessionOptions) => {
|
||||
const directoryKey = pathKey(session.directory)
|
||||
const project =
|
||||
home.project
|
||||
.list()
|
||||
.find(
|
||||
(item) =>
|
||||
pathKey(item.worktree) === directoryKey ||
|
||||
item.sandboxes?.some((sandbox) => pathKey(sandbox) === directoryKey),
|
||||
) ?? projectForSession(session, home.project.list(), projectByID())
|
||||
const conn = home.server.focused()
|
||||
if (!conn) return
|
||||
const directory = project?.worktree ?? session.directory
|
||||
const ctx = home.server.focusedContext()
|
||||
if (!ctx) return
|
||||
ctx.projects.open(directory)
|
||||
if (options?.background) {
|
||||
tabs.addSessionTab({ server: ServerConnection.key(conn), sessionId: session.id })
|
||||
return
|
||||
}
|
||||
ctx.projects.touch(directory)
|
||||
void startTransition(() => {
|
||||
const tab = tabs.addSessionTab({ server: ServerConnection.key(conn), sessionId: session.id })
|
||||
tabs.select(tab)
|
||||
})
|
||||
},
|
||||
archive: async (session: Session) => {
|
||||
const conn = home.server.focused()
|
||||
const ctx = home.server.focusedContext()
|
||||
if (!conn || !ctx) return
|
||||
const [, setStore] = ctx.sync.child(session.directory)
|
||||
await archiveHomeSession({
|
||||
server: ServerConnection.key(conn),
|
||||
session,
|
||||
archive: (sessionID) => ctx.sdk.api.session.archive({ sessionID, directory: session.directory }),
|
||||
remove: () =>
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
const match = Binary.search(draft.session, session.id, (item) => item.id)
|
||||
if (match.found) draft.session.splice(match.index, 1)
|
||||
}),
|
||||
),
|
||||
onError: (cause) =>
|
||||
showToast({
|
||||
title: language.t("common.requestFailed"),
|
||||
description: errorMessage(cause, language.t("common.requestFailed")),
|
||||
}),
|
||||
})
|
||||
},
|
||||
},
|
||||
tab: {
|
||||
isOpen: (record: HomeSessionRecord) =>
|
||||
sessionHasOpenTab(tabs.store, home.selection.value().server, record.session),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function directories(project: LocalProject) {
|
||||
return [project.worktree, ...(project.sandboxes ?? [])]
|
||||
}
|
||||
|
||||
function buildHomeSessionRecords(input: {
|
||||
sessions: () => Session[]
|
||||
projectDirectories: () => string[]
|
||||
projects: () => LocalProject[]
|
||||
projectByID: () => Map<string, LocalProject>
|
||||
}) {
|
||||
const directories = new Set(input.projectDirectories().map(pathKey))
|
||||
const sessions = input.sessions().filter((session) => directories.has(pathKey(session.directory)))
|
||||
return [...new Map(sessions.map((session) => [session.id, session] as const)).values()]
|
||||
.sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
|
||||
.flatMap((session) => {
|
||||
const directory = pathKey(session.directory)
|
||||
const project =
|
||||
input
|
||||
.projects()
|
||||
.find(
|
||||
(item) =>
|
||||
pathKey(item.worktree) === directory || item.sandboxes?.some((sandbox) => pathKey(sandbox) === directory),
|
||||
) ?? projectForSession(session, input.projects(), input.projectByID())
|
||||
if (!project) return []
|
||||
return { session, project, projectName: displayName(project) }
|
||||
})
|
||||
}
|
||||
|
||||
export function homeSessionSearchKey(record: HomeSessionRecord) {
|
||||
return `${pathKey(record.session.directory)}:${record.session.id}`
|
||||
}
|
||||
|
||||
function groupSessions(records: HomeSessionRecord[], language: ReturnType<typeof useLanguage>): HomeSessionGroup[] {
|
||||
const now = DateTime.local()
|
||||
const yesterday = now.minus({ days: 1 })
|
||||
const todaySessions = records.filter((record) =>
|
||||
DateTime.fromMillis(record.session.time.updated ?? record.session.time.created).hasSame(now, "day"),
|
||||
)
|
||||
const yesterdaySessions = records.filter((record) =>
|
||||
DateTime.fromMillis(record.session.time.updated ?? record.session.time.created).hasSame(yesterday, "day"),
|
||||
)
|
||||
const olderSessions = records.filter((record) => {
|
||||
const time = DateTime.fromMillis(record.session.time.updated ?? record.session.time.created)
|
||||
return !time.hasSame(now, "day") && !time.hasSame(yesterday, "day")
|
||||
})
|
||||
const olderTitle =
|
||||
todaySessions.length === 0 && yesterdaySessions.length === 0
|
||||
? language.t("sidebar.project.recentSessions")
|
||||
: language.t("home.sessions.group.older")
|
||||
return [
|
||||
{ id: "today" as const, title: language.t("home.sessions.group.today"), sessions: todaySessions },
|
||||
{ id: "yesterday" as const, title: language.t("home.sessions.group.yesterday"), sessions: yesterdaySessions },
|
||||
{ id: "older" as const, title: olderTitle, sessions: olderSessions },
|
||||
].filter((group) => group.sessions.length > 0)
|
||||
}
|
||||
|
||||
export type HomeSessionsController = ReturnType<typeof createHomeSessionsController>
|
||||
|
||||
export function HomeSessionStatusController(props: {
|
||||
server: Accessor<ServerConnection.Key>
|
||||
record: HomeSessionRecord
|
||||
isOpenTab: (record: HomeSessionRecord) => boolean
|
||||
render: (state: { unread: Accessor<boolean>; loading: Accessor<boolean>; open: Accessor<boolean> }) => JSX.Element
|
||||
}) {
|
||||
const avatar = useSessionTabAvatarState(
|
||||
props.server,
|
||||
() => props.record.session.directory,
|
||||
() => props.record.session.id,
|
||||
)
|
||||
return props.render({
|
||||
unread: avatar.unread,
|
||||
loading: avatar.loading,
|
||||
open: () => props.isOpenTab(props.record),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,550 @@
|
||||
import type { Session } from "@opencode-ai/sdk/v2/client"
|
||||
import { type Accessor, createMemo, For, Show } from "solid-js"
|
||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
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 { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import { SessionTabAvatarView } from "@/pages/layout/session-tab-avatar"
|
||||
import { sessionTitle } from "@/utils/session-title"
|
||||
import { shouldOpenSessionInBackground } from "../home-session-open"
|
||||
import {
|
||||
HomeSessionStatusController,
|
||||
homeSessionSearchKey,
|
||||
type HomeSessionGroup,
|
||||
type HomeSessionRecord,
|
||||
type OpenSessionOptions,
|
||||
} from "./home-sessions-controller"
|
||||
|
||||
const SHOW_HOME_SESSION_ARCHIVE = false
|
||||
const HOME_SECTION_LABEL = "text-v2-text-text-muted [font-weight:440]"
|
||||
const HOME_SESSION_SEARCH_RESULTS_ID = "home-session-search-results"
|
||||
|
||||
// Middle-click or Cmd+click on macOS (Ctrl+click elsewhere) opens a session
|
||||
// tab in the background without navigating, matching browser conventions.
|
||||
function isBackgroundOpen(event: MouseEvent) {
|
||||
return shouldOpenSessionInBackground({
|
||||
button: event.button,
|
||||
mac: typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform),
|
||||
meta: event.metaKey,
|
||||
ctrl: event.ctrlKey,
|
||||
shift: event.shiftKey,
|
||||
alt: event.altKey,
|
||||
})
|
||||
}
|
||||
|
||||
export type HomeSessionsViewProps = {
|
||||
language: ReturnType<typeof useLanguage>
|
||||
groups: Accessor<HomeSessionGroup[]>
|
||||
loading: Accessor<boolean>
|
||||
showProjectName: Accessor<boolean>
|
||||
server: Accessor<ServerConnection.Key>
|
||||
canCreateSession: Accessor<boolean>
|
||||
searchValue: Accessor<string>
|
||||
searchPlaceholder: Accessor<string>
|
||||
searchOpen: Accessor<boolean>
|
||||
searchLoading: Accessor<boolean>
|
||||
searchResults: Accessor<HomeSessionRecord[]>
|
||||
searchActive: Accessor<string>
|
||||
searchNoResultsLabel: Accessor<string>
|
||||
titleOpacity: (id: HomeSessionGroup["id"]) => number
|
||||
isOpenTab: (record: HomeSessionRecord) => boolean
|
||||
onCreateSession: () => void
|
||||
onOpenSession: (session: Session, options?: OpenSessionOptions) => void
|
||||
onArchiveSession: (session: Session) => Promise<void>
|
||||
onSetHoverTarget: (element: HTMLElement) => void
|
||||
onSetThumbTrack: (element: HTMLDivElement) => void
|
||||
onSetContent: (element: HTMLDivElement) => void
|
||||
onSetHeader: (id: HomeSessionGroup["id"], element: HTMLDivElement) => void
|
||||
onWheel: (event: WheelEvent) => void
|
||||
onSetSearchRoot: (element: HTMLDivElement) => void
|
||||
onSetSearchInput: (element: HTMLInputElement) => void
|
||||
onSetSearchList: (element: HTMLDivElement) => void
|
||||
onSearchFocus: () => void
|
||||
onSearchInput: (value: string) => void
|
||||
onSearchClose: () => void
|
||||
onSearchMove: (delta: number) => void
|
||||
onSearchSelectActive: () => void
|
||||
onSearchHighlight: (record: HomeSessionRecord) => void
|
||||
onSearchSelect: (record: HomeSessionRecord, options?: OpenSessionOptions) => void
|
||||
}
|
||||
|
||||
export function HomeSessionsView(props: HomeSessionsViewProps) {
|
||||
return (
|
||||
<section
|
||||
ref={props.onSetHoverTarget}
|
||||
class="min-h-0 min-w-0 flex-1 flex flex-col"
|
||||
aria-label={props.language.t("sidebar.project.recentSessions")}
|
||||
>
|
||||
<div class="sticky top-0 z-30 shrink-0 bg-v2-background-bg-base pb-3 pt-6 lg:pt-12" onWheel={props.onWheel}>
|
||||
<HomeSessionSearch {...props} />
|
||||
<Show when={props.groups().length > 0 && props.canCreateSession()}>
|
||||
<div class="pointer-events-none absolute right-0 top-[84px] z-20 flex lg:top-[108px]">
|
||||
<ButtonV2
|
||||
data-action="home-new-session"
|
||||
variant="ghost-muted"
|
||||
size="normal"
|
||||
icon="edit"
|
||||
class="pointer-events-auto h-7 px-2 [font-weight:530]"
|
||||
onClick={props.onCreateSession}
|
||||
>
|
||||
{props.language.t("command.session.new")}
|
||||
</ButtonV2>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="pointer-events-none sticky top-[84px] z-40 h-0 -mr-3 lg:top-[108px]">
|
||||
<div
|
||||
ref={props.onSetThumbTrack}
|
||||
data-component="home-session-scroll-track"
|
||||
class="relative ml-auto h-[calc(100cqh-84px)] w-3 lg:h-[calc(100cqh-108px)]"
|
||||
/>
|
||||
</div>
|
||||
<div class="-mr-3 min-h-[calc(100cqh-72px)] lg:min-h-[calc(100cqh-96px)]">
|
||||
<Show
|
||||
when={!props.loading()}
|
||||
fallback={
|
||||
<div class="pt-3">
|
||||
<HomeSessionSkeleton label={props.language.t("common.loading")} />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={props.groups().length > 0}
|
||||
fallback={
|
||||
<HomeSessionsEmpty
|
||||
onNewSession={props.canCreateSession() ? props.onCreateSession : undefined}
|
||||
language={props.language}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div ref={props.onSetContent} class="flex flex-col pt-3 pr-3 pb-16">
|
||||
<For each={props.groups()}>
|
||||
{(group, index) => (
|
||||
<>
|
||||
<HomeSessionGroupHeader
|
||||
title={group.title}
|
||||
titleOpacity={props.titleOpacity(group.id)}
|
||||
onSetRef={(element) => props.onSetHeader(group.id, element)}
|
||||
elevated={index() === 0}
|
||||
/>
|
||||
<div
|
||||
class={`flex min-w-0 flex-col gap-px pt-4 ${index() === props.groups().length - 1 ? "" : "mb-6"}`}
|
||||
>
|
||||
<For each={group.sessions}>{(record) => <HomeSessionRow {...props} record={record} />}</For>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeSessionLeadingController(props: {
|
||||
server: HomeSessionsViewProps["server"]
|
||||
isOpenTab: HomeSessionsViewProps["isOpenTab"]
|
||||
record: HomeSessionRecord
|
||||
revealProjectOnHover: boolean
|
||||
}) {
|
||||
return (
|
||||
<HomeSessionStatusController
|
||||
server={props.server}
|
||||
record={props.record}
|
||||
isOpenTab={props.isOpenTab}
|
||||
render={(state) => (
|
||||
<HomeSessionLeading
|
||||
record={props.record}
|
||||
revealProjectOnHover={props.revealProjectOnHover}
|
||||
open={state.open()}
|
||||
unread={state.unread()}
|
||||
loading={state.loading()}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeSessionLeading(props: {
|
||||
record: HomeSessionRecord
|
||||
revealProjectOnHover: boolean
|
||||
open: boolean
|
||||
unread: boolean
|
||||
loading: boolean
|
||||
}) {
|
||||
return (
|
||||
<div class="relative shrink-0">
|
||||
<Show when={props.open}>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class={`
|
||||
pointer-events-none absolute top-1/2 h-3 w-0.5 -translate-y-1/2
|
||||
rounded-[2px] bg-v2-background-bg-layer-04
|
||||
`}
|
||||
style={{ right: "calc(100% + 4px)" }}
|
||||
/>
|
||||
</Show>
|
||||
<SessionTabAvatarView
|
||||
project={props.record.project}
|
||||
directory={props.record.session.directory}
|
||||
revealProjectOnHover={props.revealProjectOnHover}
|
||||
unread={props.unread}
|
||||
loading={props.loading}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeSessionSearch(props: HomeSessionsViewProps) {
|
||||
return (
|
||||
<div class="w-full">
|
||||
<div ref={props.onSetSearchRoot} data-component="home-session-search" class="relative z-30 w-full">
|
||||
<Show when={props.searchOpen()}>
|
||||
<div
|
||||
data-component="home-session-search-panel"
|
||||
class={`
|
||||
absolute flex flex-col overflow-hidden rounded-[12px]
|
||||
bg-v2-background-bg-base shadow-[var(--v2-elevation-floating)]
|
||||
`}
|
||||
style={{ top: "-6px", left: "-6px", width: "calc(100% + 12px)" }}
|
||||
>
|
||||
<div class="flex flex-col pt-9">
|
||||
<div id={HOME_SESSION_SEARCH_RESULTS_ID} role="listbox" class="flex flex-col gap-4 pt-4">
|
||||
<Show
|
||||
when={!props.searchLoading()}
|
||||
fallback={
|
||||
<div class="flex items-center justify-center px-4 py-3 text-v2-text-text-muted [font-weight:440]">
|
||||
<Spinner class="size-4" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={props.searchResults().length > 0}
|
||||
fallback={
|
||||
<p
|
||||
class={`
|
||||
my-1.5 px-4 pb-2 text-[13px] leading-4 tracking-[-0.04px]
|
||||
text-v2-text-text-muted [font-weight:440]
|
||||
`}
|
||||
>
|
||||
{props.searchNoResultsLabel()}
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<p
|
||||
class={`
|
||||
my-1.5 pl-[18px] pr-6 text-[13px] leading-4 tracking-[-0.04px]
|
||||
text-v2-text-text-muted [font-weight:440]
|
||||
`}
|
||||
>
|
||||
{props.language.t("home.sessions.search.sessions")}
|
||||
</p>
|
||||
<ScrollView class="max-h-80" viewportRef={props.onSetSearchList}>
|
||||
<div class="flex flex-col gap-px pb-2">
|
||||
<For each={props.searchResults()}>
|
||||
{(record) => (
|
||||
<HomeSessionSearchResultRow
|
||||
{...props}
|
||||
record={record}
|
||||
selected={props.searchActive() === homeSessionSearchKey(record)}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</ScrollView>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<label
|
||||
class={`
|
||||
relative z-20 flex h-9 w-full items-center gap-2 rounded-[6px] py-1 pl-3 pr-2
|
||||
bg-v2-background-bg-layer-02/60 text-v2-icon-icon-muted transition-[background-color,box-shadow]
|
||||
duration-[120ms] ease-in-out hover:bg-v2-background-bg-layer-02 focus-within:bg-v2-background-bg-layer-02
|
||||
`}
|
||||
>
|
||||
<IconV2 name="magnifying-glass" />
|
||||
<input
|
||||
ref={props.onSetSearchInput}
|
||||
class={`
|
||||
relative z-20 min-w-0 flex-1 border-0 bg-transparent outline-0
|
||||
text-v2-text-text-base [font-weight:440] placeholder:text-v2-text-text-faint
|
||||
`}
|
||||
value={props.searchValue()}
|
||||
placeholder={props.searchPlaceholder()}
|
||||
aria-label={props.searchPlaceholder()}
|
||||
aria-expanded={props.searchOpen()}
|
||||
aria-controls={HOME_SESSION_SEARCH_RESULTS_ID}
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={
|
||||
props.searchActive() && props.searchOpen()
|
||||
? `home-session-search-option-${props.searchActive()}`
|
||||
: undefined
|
||||
}
|
||||
onFocus={props.onSearchFocus}
|
||||
onInput={(event) => props.onSearchInput(event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
props.onSearchClose()
|
||||
event.currentTarget.blur()
|
||||
return
|
||||
}
|
||||
if (!props.searchOpen() || props.searchResults().length === 0) return
|
||||
if (event.altKey || event.metaKey) return
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault()
|
||||
props.onSearchMove(1)
|
||||
return
|
||||
}
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault()
|
||||
props.onSearchMove(-1)
|
||||
return
|
||||
}
|
||||
if (event.key === "Enter" && !event.isComposing) {
|
||||
event.preventDefault()
|
||||
props.onSearchSelectActive()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Show when={props.searchValue()}>
|
||||
<IconButtonV2
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
class="relative z-20 shrink-0"
|
||||
icon={<IconV2 name="close" size="large" class="text-v2-icon-icon-muted" />}
|
||||
aria-label={props.searchPlaceholder()}
|
||||
onClick={() => {
|
||||
props.onSearchClose()
|
||||
props.onSearchFocus()
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeSessionSearchResultRow(
|
||||
props: HomeSessionsViewProps & {
|
||||
record: HomeSessionRecord
|
||||
selected: boolean
|
||||
},
|
||||
) {
|
||||
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
|
||||
const showProjectName = () => props.showProjectName() && props.record.projectName
|
||||
const key = () => homeSessionSearchKey(props.record)
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
id={`home-session-search-option-${key()}`}
|
||||
data-key={key()}
|
||||
data-component="home-session-search-row"
|
||||
role="option"
|
||||
aria-selected={props.selected}
|
||||
class={`
|
||||
flex h-10 w-full shrink-0 cursor-default items-center gap-2 border-0 py-3 pl-[18px] pr-6 text-left
|
||||
transition-[background-color] duration-[120ms] ease-in-out
|
||||
hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none
|
||||
`}
|
||||
classList={{
|
||||
"bg-v2-overlay-simple-overlay-hover": props.selected,
|
||||
group: !!showProjectName(),
|
||||
}}
|
||||
onMouseEnter={() => props.onSearchHighlight(props.record)}
|
||||
onMouseDown={(event) => {
|
||||
if (event.button === 1) event.preventDefault()
|
||||
}}
|
||||
onClick={(event) => props.onSearchSelect(props.record, { background: isBackgroundOpen(event) })}
|
||||
onAuxClick={(event) => {
|
||||
if (!isBackgroundOpen(event)) return
|
||||
event.preventDefault()
|
||||
props.onSearchSelect(props.record, { background: true })
|
||||
}}
|
||||
>
|
||||
<HomeSessionLeadingController
|
||||
server={props.server}
|
||||
isOpenTab={props.isOpenTab}
|
||||
record={props.record}
|
||||
revealProjectOnHover={!!showProjectName()}
|
||||
/>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
<HomeSessionTitle title={title()} showProjectName={!!showProjectName()} search />
|
||||
<Show when={showProjectName()}>
|
||||
<HomeSessionProjectName name={props.record.projectName} search />
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeSessionGroupHeader(props: {
|
||||
title: string
|
||||
titleOpacity: number
|
||||
onSetRef: (element: HTMLDivElement) => void
|
||||
elevated?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
ref={props.onSetRef}
|
||||
class={`
|
||||
pointer-events-none sticky top-[84px] flex h-7 min-w-0 items-center justify-between
|
||||
bg-v2-background-bg-base pl-3 lg:top-[108px]
|
||||
`}
|
||||
classList={{ "home-session-group-header z-[5]": !!props.elevated, "z-10": !props.elevated }}
|
||||
>
|
||||
<div class={HOME_SECTION_LABEL} style={{ opacity: props.titleOpacity }}>
|
||||
{props.title}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeSessionRow(props: HomeSessionsViewProps & { record: HomeSessionRecord }) {
|
||||
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
|
||||
const showProjectName = () => props.showProjectName() && props.record.projectName
|
||||
|
||||
return (
|
||||
<div
|
||||
class="group/session relative flex h-10 min-w-0 items-center rounded-[6px]"
|
||||
classList={{ group: !!showProjectName() }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-component="home-session-row"
|
||||
class={`
|
||||
flex h-10 min-w-0 w-full flex-1 shrink-0 cursor-default items-center gap-2 rounded-[6px] border-0
|
||||
bg-transparent py-3 pl-3 pr-10 text-left text-v2-text-text-muted [font-weight:530]
|
||||
transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out
|
||||
hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none
|
||||
`}
|
||||
onMouseDown={(event) => {
|
||||
if (event.button === 1) event.preventDefault()
|
||||
}}
|
||||
onClick={(event) => props.onOpenSession(props.record.session, { background: isBackgroundOpen(event) })}
|
||||
onAuxClick={(event) => {
|
||||
if (!isBackgroundOpen(event)) return
|
||||
event.preventDefault()
|
||||
props.onOpenSession(props.record.session, { background: true })
|
||||
}}
|
||||
>
|
||||
<HomeSessionLeadingController
|
||||
server={props.server}
|
||||
isOpenTab={props.isOpenTab}
|
||||
record={props.record}
|
||||
revealProjectOnHover={!!showProjectName()}
|
||||
/>
|
||||
<HomeSessionTitle title={title()} showProjectName={!!showProjectName()} />
|
||||
<Show when={showProjectName()}>
|
||||
<HomeSessionProjectName name={props.record.projectName} />
|
||||
</Show>
|
||||
</button>
|
||||
<Show when={SHOW_HOME_SESSION_ARCHIVE}>
|
||||
<div
|
||||
class={`
|
||||
hover-reveal absolute right-1.5 top-1/2 flex -translate-y-1/2 items-center gap-1
|
||||
group-hover/session:opacity-100 focus-within:opacity-100
|
||||
`}
|
||||
>
|
||||
<TooltipV2 class="flex shrink-0 items-center" placement="bottom" value={props.language.t("common.archive")}>
|
||||
<IconButtonV2
|
||||
data-action="home-session-archive"
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
icon={<IconV2 name="archive" />}
|
||||
aria-label={props.language.t("common.archive")}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
void props.onArchiveSession(props.record.session)
|
||||
}}
|
||||
/>
|
||||
</TooltipV2>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeSessionTitle(props: { title: string; showProjectName: boolean; search?: boolean }) {
|
||||
return (
|
||||
<span
|
||||
class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-base [font-weight:530]"
|
||||
classList={{
|
||||
"text-[13px] leading-4 tracking-[-0.04px]": !!props.search,
|
||||
"max-w-[min(70%,480px)] flex-[0_1_auto]": props.showProjectName,
|
||||
"flex-[1_1_auto]": !props.showProjectName,
|
||||
}}
|
||||
>
|
||||
{props.title}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeSessionProjectName(props: { name: string; search?: boolean }) {
|
||||
return (
|
||||
<span
|
||||
class="min-w-0 flex-[1_1_auto] overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-muted [font-weight:440]"
|
||||
classList={{ "text-[13px] leading-4 tracking-[-0.04px]": !!props.search }}
|
||||
>
|
||||
{props.name}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeSessionsEmpty(props: { onNewSession?: () => void; language: ReturnType<typeof useLanguage> }) {
|
||||
return (
|
||||
<div class="flex min-h-full flex-col items-center gap-4 px-6 pt-[52px] text-center">
|
||||
<div
|
||||
class={`
|
||||
shrink-0 text-[13px] leading-[13px] tracking-[-0.04px]
|
||||
text-v2-text-text-base [font-weight:530]
|
||||
`}
|
||||
>
|
||||
{props.language.t("home.sessions.empty")}
|
||||
</div>
|
||||
<p
|
||||
class={`
|
||||
mb-1 text-center text-[13px] leading-5 tracking-[-0.04px]
|
||||
text-v2-text-text-muted [font-weight:440]
|
||||
`}
|
||||
>
|
||||
{props.language.t("home.sessions.empty.description")}
|
||||
</p>
|
||||
<Show when={props.onNewSession}>
|
||||
{(onNewSession) => (
|
||||
<ButtonV2 data-action="home-new-session" variant="neutral" size="normal" icon="edit" onClick={onNewSession()}>
|
||||
{props.language.t("command.session.new")}
|
||||
</ButtonV2>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeSessionSkeleton(props: { label: string }) {
|
||||
return (
|
||||
<div class="flex min-w-0 flex-col gap-4">
|
||||
<div class="flex h-7 min-w-0 items-center justify-between px-4">
|
||||
<div class={HOME_SECTION_LABEL}>{props.label}</div>
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-col gap-px" aria-hidden="true">
|
||||
<For each={[0, 1, 2, 3]}>{() => <div class="h-10 rounded-[6px] bg-v2-background-bg-deep opacity-70" />}</For>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { HomeScrollController } from "./home-scroll-controller"
|
||||
import type { HomeSessionSearchController } from "./home-session-search-controller"
|
||||
import type { HomeSessionsController } from "./home-sessions-controller"
|
||||
import { HomeSessionsView } from "./home-sessions-view"
|
||||
|
||||
export function HomeSessions(props: {
|
||||
sessions: HomeSessionsController
|
||||
search: HomeSessionSearchController
|
||||
scroll: HomeScrollController
|
||||
}) {
|
||||
return (
|
||||
<HomeSessionsView
|
||||
language={props.sessions.copy.language}
|
||||
groups={props.sessions.data.groups}
|
||||
loading={props.sessions.data.loading}
|
||||
showProjectName={props.sessions.session.showProjectName}
|
||||
server={props.sessions.session.server}
|
||||
canCreateSession={props.sessions.session.canCreate}
|
||||
searchValue={props.search.query.value}
|
||||
searchPlaceholder={props.search.query.placeholder}
|
||||
searchOpen={props.search.query.open}
|
||||
searchLoading={props.search.result.loading}
|
||||
searchResults={props.search.result.list}
|
||||
searchActive={props.search.result.active}
|
||||
searchNoResultsLabel={props.search.result.noResultsLabel}
|
||||
titleOpacity={props.scroll.header.titleOpacity}
|
||||
isOpenTab={props.sessions.tab.isOpen}
|
||||
onCreateSession={props.sessions.session.create}
|
||||
onOpenSession={props.sessions.session.open}
|
||||
onArchiveSession={props.sessions.session.archive}
|
||||
onSetHoverTarget={props.scroll.viewport.setHoverTarget}
|
||||
onSetThumbTrack={props.scroll.viewport.setThumbTrack}
|
||||
onSetContent={props.scroll.header.setContent}
|
||||
onSetHeader={props.scroll.header.setHeader}
|
||||
onWheel={props.scroll.viewport.containWheel}
|
||||
onSetSearchRoot={props.search.element.setRoot}
|
||||
onSetSearchInput={props.search.element.setInput}
|
||||
onSetSearchList={props.search.element.setList}
|
||||
onSearchFocus={props.search.query.focus}
|
||||
onSearchInput={props.search.query.input}
|
||||
onSearchClose={props.search.query.close}
|
||||
onSearchMove={props.search.result.move}
|
||||
onSearchSelectActive={props.search.result.selectActive}
|
||||
onSearchHighlight={props.search.result.highlight}
|
||||
onSearchSelect={props.search.result.select}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { DialogSelectServer } from "@/components/dialog-select-server"
|
||||
import { useDirectoryPicker } from "@/components/directory-picker"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { type ServerConnection, useServer } from "@/context/server"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Logo } from "@opencode-ai/ui/logo"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { DateTime } from "luxon"
|
||||
import { createMemo, For, Match, Switch } from "solid-js"
|
||||
|
||||
export function LegacyHome() {
|
||||
const sync = useServerSync()
|
||||
const pickDirectory = useDirectoryPicker()
|
||||
const dialog = useDialog()
|
||||
const navigate = useNavigate()
|
||||
const global = useGlobal()
|
||||
const server = useServer()
|
||||
const language = useLanguage()
|
||||
const homedir = createMemo(() => sync().data.path.home)
|
||||
const serverUnreachable = createMemo(() => global.servers.health[server.key]?.healthy === false)
|
||||
const recent = createMemo(() => {
|
||||
return sync()
|
||||
.data.project.slice()
|
||||
.sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
|
||||
.slice(0, 5)
|
||||
})
|
||||
|
||||
const serverDotClass = createMemo(() => {
|
||||
const healthy = global.servers.health[server.key]?.healthy
|
||||
if (healthy === true) return "bg-icon-success-base"
|
||||
if (healthy === false) return "bg-icon-critical-base"
|
||||
return "bg-border-weak-base"
|
||||
})
|
||||
|
||||
function openProject(conn: ServerConnection.Any, directory: string) {
|
||||
const serverCtx = global.ensureServerCtx(conn)
|
||||
serverCtx.projects.open(directory)
|
||||
serverCtx.projects.touch(directory)
|
||||
navigate(`/${base64Encode(directory)}`)
|
||||
}
|
||||
|
||||
function chooseProject() {
|
||||
if (serverUnreachable()) return
|
||||
const conn = server.current
|
||||
if (!conn) return
|
||||
|
||||
const resolve = (result: string | string[] | null) => {
|
||||
if (Array.isArray(result)) {
|
||||
result.forEach((directory) => openProject(conn, directory))
|
||||
return
|
||||
}
|
||||
if (result) openProject(conn, result)
|
||||
}
|
||||
|
||||
pickDirectory({
|
||||
server: conn,
|
||||
title: language.t("command.project.open"),
|
||||
multiple: true,
|
||||
onSelect: resolve,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="mx-auto mt-55 w-full md:w-auto px-4">
|
||||
<Logo class="md:w-xl opacity-12" />
|
||||
<Button
|
||||
size="large"
|
||||
variant="ghost"
|
||||
class="mt-4 mx-auto text-14-regular text-text-weak"
|
||||
onClick={() => dialog.show(() => <DialogSelectServer />)}
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
"size-2 rounded-full": true,
|
||||
[serverDotClass()]: true,
|
||||
}}
|
||||
/>
|
||||
{server.name}
|
||||
</Button>
|
||||
<Switch>
|
||||
<Match when={sync().data.project.length > 0}>
|
||||
<div class="mt-20 w-full flex flex-col gap-4">
|
||||
<div class="flex gap-2 items-center justify-between pl-3">
|
||||
<div class="text-14-medium text-text-strong">{language.t("home.recentProjects")}</div>
|
||||
<Button
|
||||
icon="folder-add-left"
|
||||
size="normal"
|
||||
class="pl-2 pr-3"
|
||||
disabled={serverUnreachable()}
|
||||
onClick={chooseProject}
|
||||
>
|
||||
{language.t("command.project.open")}
|
||||
</Button>
|
||||
</div>
|
||||
<ul class="flex flex-col gap-2">
|
||||
<For each={recent()}>
|
||||
{(project) => (
|
||||
<Button
|
||||
size="large"
|
||||
variant="ghost"
|
||||
class="text-14-mono text-left justify-between px-3"
|
||||
onClick={() => openProject(server.current!, project.worktree)}
|
||||
>
|
||||
{project.worktree.replace(homedir(), "~")}
|
||||
<div class="text-14-regular text-text-weak">
|
||||
{DateTime.fromMillis(project.time.updated ?? project.time.created).toRelative()}
|
||||
</div>
|
||||
</Button>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={!sync().ready}>
|
||||
<div class="mt-30 mx-auto flex flex-col items-center gap-3">
|
||||
<div class="text-12-regular text-text-weak">{language.t("common.loading")}</div>
|
||||
<Button class="px-3" disabled={serverUnreachable()} onClick={chooseProject}>
|
||||
{language.t("command.project.open")}
|
||||
</Button>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<div class="mt-30 mx-auto flex flex-col items-center gap-3">
|
||||
<Icon name="folder-add-left" size="large" />
|
||||
<div class="flex flex-col gap-1 items-center justify-center">
|
||||
<div class="text-14-medium text-text-strong">{language.t("home.empty.title")}</div>
|
||||
<div class="text-12-regular text-text-weak">{language.t("home.empty.description")}</div>
|
||||
</div>
|
||||
<Button class="px-3 mt-1" disabled={serverUnreachable()} onClick={chooseProject}>
|
||||
{language.t("command.project.open")}
|
||||
</Button>
|
||||
</div>
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -36,6 +36,7 @@ import { useProviders } from "@/hooks/use-providers"
|
||||
import { toaster } from "@opencode-ai/ui/toast"
|
||||
import { setV2Toast, showToast, ToastRegion } from "@/utils/toast"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { normalizeProjectInfo } from "@/context/global-sync/utils"
|
||||
import { clearWorkspaceTerminals } from "@/context/terminal"
|
||||
import { pickSessionCacheEvictions } from "@/context/global-sync/session-cache"
|
||||
import { useNotification } from "@/context/notification"
|
||||
@@ -48,6 +49,7 @@ import { setNavigate } from "@/utils/notification-click"
|
||||
import { Worktree as WorktreeState } from "@/utils/worktree"
|
||||
import { setSessionHandoff } from "@/pages/session/handoff"
|
||||
import { SessionRouteKey, SessionStateKey } from "@/utils/server-scope"
|
||||
import { listAllSessions } from "@/utils/session"
|
||||
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
|
||||
@@ -875,11 +877,7 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
const index = sessions.findIndex((s) => s.id === session.id)
|
||||
const nextSession = sessions[index + 1] ?? sessions[index - 1]
|
||||
|
||||
await serverSDK().client.session.update({
|
||||
directory: session.directory,
|
||||
sessionID: session.id,
|
||||
time: { archived: Date.now() },
|
||||
})
|
||||
await serverSDK().api.session.archive({ sessionID: session.id, directory: session.directory })
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
const match = Binary.search(draft.session, session.id, (s) => s.id)
|
||||
@@ -1185,9 +1183,12 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
}
|
||||
const refreshDirs = async (target?: string) => {
|
||||
if (!target || target === root || canOpen(target)) return canOpen(target)
|
||||
const listed = await serverSDK()
|
||||
.client.worktree.list({ directory: root })
|
||||
.then((x) => x.data ?? [])
|
||||
const listed = await Promise.resolve(
|
||||
project?.id ?? serverSDK().api.project.current({ location: { directory: root } }),
|
||||
)
|
||||
.then((value) => (typeof value === "string" ? value : value.id))
|
||||
.then((projectID) => serverSDK().api.project.directories({ projectID, location: { directory: root } }))
|
||||
.then((items) => items.map((item) => item.directory).filter((item) => pathKey(item) !== pathKey(root)))
|
||||
.catch(() => [] as string[])
|
||||
dirs = effectiveWorkspaceOrder(root, [root, ...listed], store.workspaceOrder[root])
|
||||
return canOpen(target)
|
||||
@@ -1231,10 +1232,11 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
await Promise.all(
|
||||
dirs.map(async (item) => ({
|
||||
path: { directory: item },
|
||||
session: await serverSDK()
|
||||
.client.session.list({ directory: item })
|
||||
.then((x) => x.data ?? [])
|
||||
.catch(() => []),
|
||||
session: await listAllSessions(serverSDK().api.session, {
|
||||
directory: item,
|
||||
parentID: null,
|
||||
order: "desc",
|
||||
}).catch(() => []),
|
||||
})),
|
||||
),
|
||||
Date.now(),
|
||||
@@ -1294,7 +1296,10 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
const name = next === getFilename(project.worktree) ? "" : next
|
||||
|
||||
if (project.id && project.id !== "global") {
|
||||
await serverSDK().client.project.update({ projectID: project.id, directory: project.worktree, name })
|
||||
const result = await serverSDK().api.project.update({ projectID: project.id, name })
|
||||
serverSync().set("project", (items) =>
|
||||
items.map((item) => (item.id === result.id ? normalizeProjectInfo(result) : item)),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1445,10 +1450,7 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
})
|
||||
const dismiss = () => toaster.dismiss(progress)
|
||||
|
||||
const sessions: Session[] = await serverSDK()
|
||||
.client.session.list({ directory })
|
||||
.then((x) => x.data ?? [])
|
||||
.catch(() => [])
|
||||
const sessions = await listAllSessions(serverSDK().api.session, { directory, order: "desc" }).catch(() => [])
|
||||
|
||||
clearWorkspaceTerminals(
|
||||
directory,
|
||||
@@ -1477,17 +1479,12 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
return
|
||||
}
|
||||
|
||||
const archivedAt = Date.now()
|
||||
await Promise.all(
|
||||
sessions
|
||||
.filter((session) => session.time.archived === undefined)
|
||||
.map((session) =>
|
||||
serverSDK()
|
||||
.client.session.update({
|
||||
sessionID: session.id,
|
||||
directory: session.directory,
|
||||
time: { archived: archivedAt },
|
||||
})
|
||||
.api.session.archive({ sessionID: session.id, directory: session.directory })
|
||||
.catch(() => undefined),
|
||||
),
|
||||
)
|
||||
@@ -1524,9 +1521,9 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
|
||||
onMount(() => {
|
||||
serverSDK()
|
||||
.client.vcs.status({ directory: props.directory })
|
||||
.then((x) => {
|
||||
const files = x.data ?? []
|
||||
.api.vcs.status({ location: { directory: props.directory } })
|
||||
.then((result) => {
|
||||
const files = result.data
|
||||
const dirty = files.length > 0
|
||||
setData({ status: "ready", dirty })
|
||||
})
|
||||
@@ -1582,19 +1579,19 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
})
|
||||
|
||||
const refresh = async () => {
|
||||
const sessions = await serverSDK()
|
||||
.client.session.list({ directory: props.directory })
|
||||
.then((x) => x.data ?? [])
|
||||
.catch(() => [])
|
||||
const sessions = await listAllSessions(serverSDK().api.session, {
|
||||
directory: props.directory,
|
||||
order: "desc",
|
||||
}).catch(() => [])
|
||||
const active = sessions.filter((session) => session.time.archived === undefined)
|
||||
setState({ sessions: active })
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
serverSDK()
|
||||
.client.vcs.status({ directory: props.directory })
|
||||
.then((x) => {
|
||||
const files = x.data ?? []
|
||||
.api.vcs.status({ location: { directory: props.directory } })
|
||||
.then((result) => {
|
||||
const files = result.data
|
||||
const dirty = files.length > 0
|
||||
setState({ status: "ready", dirty })
|
||||
void refresh()
|
||||
|
||||
@@ -19,16 +19,34 @@ export function SessionTabAvatar(props: {
|
||||
() => props.directory,
|
||||
() => props.sessionId,
|
||||
)
|
||||
return (
|
||||
<SessionTabAvatarView
|
||||
project={props.project}
|
||||
directory={props.directory}
|
||||
revealProjectOnHover={props.revealProjectOnHover}
|
||||
unread={state.unread()}
|
||||
loading={state.loading()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function SessionTabAvatarView(props: {
|
||||
project?: LocalProject
|
||||
directory: string
|
||||
revealProjectOnHover?: boolean
|
||||
unread: boolean
|
||||
loading: boolean
|
||||
}) {
|
||||
const projectAvatar = () => (
|
||||
<ProjectAvatar
|
||||
fallback={displayName(props.project ?? { worktree: props.directory })}
|
||||
src={getProjectAvatarSource(props.project?.id, props.project?.icon)}
|
||||
variant={getProjectAvatarVariant(props.project?.icon?.color)}
|
||||
unread={state.unread()}
|
||||
unread={props.unread}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<Show when={state.loading()} fallback={projectAvatar()}>
|
||||
<Show when={props.loading} fallback={projectAvatar()}>
|
||||
<span class="relative block size-4 shrink-0">
|
||||
<SessionProgressIndicatorV2
|
||||
class={`absolute inset-0 ${props.revealProjectOnHover === false ? "" : "group-hover:invisible"}`}
|
||||
|
||||
@@ -532,7 +532,6 @@ export default function Page() {
|
||||
|
||||
const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined))
|
||||
const isChildSession = createMemo(() => !!info()?.parentID)
|
||||
const diffs = createMemo(() => (params.id ? list(sync().data.session_diff[params.id]) : []))
|
||||
const canReview = createMemo(() => !!sync().project)
|
||||
const reviewTab = createMemo(() => isDesktop())
|
||||
const tabState = createSessionTabs({
|
||||
@@ -690,8 +689,8 @@ export default function Page() {
|
||||
queryFn: mode
|
||||
? () =>
|
||||
sdk()
|
||||
.client.vcs.diff({ mode })
|
||||
.then((result) => list(result.data))
|
||||
.api.vcs.diff({ location: { directory: sdk().directory }, mode: mode === "git" ? "working" : mode })
|
||||
.then((result) => result.data)
|
||||
.catch((error) => {
|
||||
console.debug("[session-review] failed to load vcs diff", { mode, error })
|
||||
return []
|
||||
@@ -738,8 +737,12 @@ export default function Page() {
|
||||
retry: 2,
|
||||
queryFn: () =>
|
||||
sdk()
|
||||
.client.vcs.diff({ mode, directory: scope, context })
|
||||
.then((result) => result.data ?? []),
|
||||
.api.vcs.diff({
|
||||
location: { directory: scope },
|
||||
mode: mode === "git" ? "working" : mode,
|
||||
context,
|
||||
})
|
||||
.then((result) => result.data),
|
||||
})
|
||||
.then((diffs) => diffs.find((diff) => diff.file === file))
|
||||
|
||||
@@ -946,10 +949,11 @@ export default function Page() {
|
||||
)
|
||||
|
||||
const stopVcs = sdk().event.listen((evt) => {
|
||||
if (evt.details.type !== "file.watcher.updated") return
|
||||
const details = evt.details as { type: string; properties?: unknown }
|
||||
if (details.type !== "file.watcher.updated" && details.type !== "filesystem.changed") return
|
||||
const props =
|
||||
typeof evt.details.properties === "object" && evt.details.properties
|
||||
? (evt.details.properties as Record<string, unknown>)
|
||||
typeof details.properties === "object" && details.properties
|
||||
? (details.properties as Record<string, unknown>)
|
||||
: undefined
|
||||
const file = typeof props?.file === "string" ? props.file : undefined
|
||||
if (!file || file.startsWith(".git/")) return
|
||||
@@ -1464,44 +1468,6 @@ export default function Page() {
|
||||
requestAnimationFrame(() => attempt(0))
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const id = params.id
|
||||
if (!id) return
|
||||
|
||||
if (!wantsReview()) return
|
||||
if (sync().data.session_diff[id] !== undefined) return
|
||||
if (sync().status === "loading") return
|
||||
|
||||
void sync().session.diff(id)
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => [sessionKey(), wantsReview()] as const,
|
||||
([key, wants]) => {
|
||||
if (diffFrame !== undefined) cancelAnimationFrame(diffFrame)
|
||||
if (diffTimer !== undefined) window.clearTimeout(diffTimer)
|
||||
diffFrame = undefined
|
||||
diffTimer = undefined
|
||||
if (!wants) return
|
||||
|
||||
const id = params.id
|
||||
if (!id) return
|
||||
if (!untrack(() => sync().data.session_diff[id] !== undefined)) return
|
||||
|
||||
diffFrame = requestAnimationFrame(() => {
|
||||
diffFrame = undefined
|
||||
diffTimer = window.setTimeout(() => {
|
||||
diffTimer = undefined
|
||||
if (sessionKey() !== key) return
|
||||
void sync().session.diff(id, { force: true })
|
||||
}, 0)
|
||||
})
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
let treeDir: string | undefined
|
||||
createEffect(() => {
|
||||
const dir = sdk().directory
|
||||
@@ -1757,7 +1723,7 @@ export default function Page() {
|
||||
setFollowup("failed", input.sessionID, undefined)
|
||||
|
||||
const ok = await sendFollowupDraft({
|
||||
client: sdk().client,
|
||||
api: sdk().api.session,
|
||||
sync: sync(),
|
||||
serverSync: serverSync(),
|
||||
draft: item,
|
||||
@@ -1853,13 +1819,13 @@ export default function Page() {
|
||||
const halt = (sessionID: string) =>
|
||||
busy(sessionID)
|
||||
? sdk()
|
||||
.client.session.abort({ sessionID })
|
||||
.api.session.interrupt({ sessionID })
|
||||
.catch(() => {})
|
||||
: Promise.resolve()
|
||||
|
||||
const revertMutation = useMutation(() => ({
|
||||
mutationFn: async (input: { sessionID: string; messageID: string }) => {
|
||||
const client = sdk().client
|
||||
const session = sdk().api.session
|
||||
const target = sync()
|
||||
const last = target.session.get(input.sessionID)?.revert
|
||||
const value = draft(input.messageID)
|
||||
@@ -1869,10 +1835,8 @@ export default function Page() {
|
||||
roll(input.sessionID, { messageID: input.messageID }, target)
|
||||
prompt.set(value)
|
||||
},
|
||||
request: () => halt(input.sessionID).then(() => client.session.revert(input)),
|
||||
complete: (result) => {
|
||||
if (result.data) merge(result.data, target)
|
||||
},
|
||||
request: () => halt(input.sessionID).then(() => session.revert.stage(input)),
|
||||
complete: () => undefined,
|
||||
rollback: () => roll(input.sessionID, last, target),
|
||||
fail,
|
||||
})
|
||||
@@ -1884,7 +1848,7 @@ export default function Page() {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
|
||||
const client = sdk().client
|
||||
const session = sdk().api.session
|
||||
const target = sync()
|
||||
const next = userMessages().find((item) => item.id > id)
|
||||
const last = target.session.get(sessionID)?.revert
|
||||
@@ -1901,11 +1865,9 @@ export default function Page() {
|
||||
},
|
||||
request: () =>
|
||||
!next
|
||||
? halt(sessionID).then(() => client.session.unrevert({ sessionID }))
|
||||
: halt(sessionID).then(() => client.session.revert({ sessionID, messageID: next.id })),
|
||||
complete: (result) => {
|
||||
if (result.data) merge(result.data, target)
|
||||
},
|
||||
? halt(sessionID).then(() => session.revert.clear({ sessionID }))
|
||||
: halt(sessionID).then(() => session.revert.stage({ sessionID, messageID: next.id }).then(() => undefined)),
|
||||
complete: () => undefined,
|
||||
rollback: () => roll(sessionID, last, target),
|
||||
fail,
|
||||
})
|
||||
|
||||
@@ -45,7 +45,8 @@ export function createPromptInputController(input: {
|
||||
model: {
|
||||
selection: input.model ?? local.model,
|
||||
paid: providers.paid().length > 0,
|
||||
loading: agentsQuery.isLoading || providersQuery.isLoading || globalProvidersQuery.isLoading,
|
||||
loading:
|
||||
(local.agent.visible() && agentsQuery.isLoading) || providersQuery.isLoading || globalProvidersQuery.isLoading,
|
||||
},
|
||||
session: {
|
||||
id: input.sessionID(),
|
||||
|
||||
@@ -82,7 +82,7 @@ export function createSessionComposerController(options?: { closeMs?: number | (
|
||||
|
||||
setStore("responding", perm.id)
|
||||
sdk()
|
||||
.client.permission.respond({ sessionID: perm.sessionID, permissionID: perm.id, response })
|
||||
.api.permission.reply({ sessionID: perm.sessionID, requestID: perm.id, reply: response })
|
||||
.catch((err: unknown) => {
|
||||
const description = err instanceof Error ? err.message : String(err)
|
||||
showToast({ title: language.t("common.requestFailed"), description })
|
||||
|
||||
@@ -223,7 +223,8 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
}
|
||||
|
||||
const replyMutation = useMutation(() => ({
|
||||
mutationFn: (answers: QuestionAnswer[]) => sdk().client.question.reply({ requestID: props.request.id, answers }),
|
||||
mutationFn: (answers: QuestionAnswer[]) =>
|
||||
sdk().api.question.reply({ sessionID: props.request.sessionID, requestID: props.request.id, answers }),
|
||||
onMutate: () => {
|
||||
props.onSubmit()
|
||||
},
|
||||
@@ -235,7 +236,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
}))
|
||||
|
||||
const rejectMutation = useMutation(() => ({
|
||||
mutationFn: () => sdk().client.question.reject({ requestID: props.request.id }),
|
||||
mutationFn: () => sdk().api.question.reject({ sessionID: props.request.sessionID, requestID: props.request.id }),
|
||||
onMutate: () => {
|
||||
props.onSubmit()
|
||||
},
|
||||
|
||||
@@ -283,6 +283,14 @@ export function MessageTimeline(props: {
|
||||
return sync().data.session_status[id] ?? idle
|
||||
})
|
||||
const sessionMessages = createMemo(() => (sessionID() ? (sync().data.message[sessionID()!] ?? []) : []))
|
||||
const projectedMessages = createMemo(() => {
|
||||
const id = sessionID()
|
||||
if (!id) return []
|
||||
const visible = new Set(props.userMessages.map((message) => message.id))
|
||||
const boundary = sessionMessages().find((message) => message.role === "user" && !visible.has(message.id))?.id
|
||||
const messages = sync().data.session_message[id] ?? []
|
||||
return boundary ? messages.filter((message) => message.id < boundary) : messages
|
||||
})
|
||||
const info = createMemo(() => {
|
||||
const id = sessionID()
|
||||
if (!id) return
|
||||
@@ -324,7 +332,7 @@ export function MessageTimeline(props: {
|
||||
const showHeader = createMemo(() => !!(titleValue() || parentID()))
|
||||
const projection = createTimelineProjection({
|
||||
messages: sessionMessages,
|
||||
userMessages: () => props.userMessages,
|
||||
sessionMessages: projectedMessages,
|
||||
parts: getMsgParts,
|
||||
status: sessionStatus,
|
||||
showReasoningSummaries: settings.general.showReasoningSummaries,
|
||||
@@ -665,7 +673,7 @@ export function MessageTimeline(props: {
|
||||
|
||||
const titleMutation = useMutation(() => ({
|
||||
mutationFn: (input: { id: string; title: string }) =>
|
||||
sdk().client.session.update({ sessionID: input.id, title: input.title }),
|
||||
sdk().api.session.rename({ sessionID: input.id, title: input.title }),
|
||||
onSuccess: (_, input) => {
|
||||
sync().set(
|
||||
produce((draft) => {
|
||||
@@ -809,7 +817,7 @@ export function MessageTimeline(props: {
|
||||
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
|
||||
|
||||
await sdk()
|
||||
.client.session.update({ sessionID, time: { archived: Date.now() } })
|
||||
.api.session.archive({ sessionID })
|
||||
.then(() => {
|
||||
sync().set(
|
||||
produce((draft) => {
|
||||
@@ -838,8 +846,8 @@ export function MessageTimeline(props: {
|
||||
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
|
||||
|
||||
const result = await sdk()
|
||||
.client.session.delete({ sessionID })
|
||||
.then((x) => x.data)
|
||||
.api.session.remove({ sessionID })
|
||||
.then(() => true)
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("session.delete.failed.title"),
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import { Binary } from "@opencode-ai/core/util/binary"
|
||||
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import type { AssistantMessage, Message, Part, SessionStatus, UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { createMemo, mapArray, type Accessor } from "solid-js"
|
||||
import { createMemo, type Accessor } from "solid-js"
|
||||
import { reuseTimelineRows } from "./row-reconciliation"
|
||||
import { Timeline, TimelineRow } from "./rows"
|
||||
|
||||
export { reuseTimelineRows } from "./row-reconciliation"
|
||||
|
||||
const emptyAssistantMessages: AssistantMessage[] = []
|
||||
|
||||
export function createTimelineProjection(input: {
|
||||
messages: Accessor<Message[]>
|
||||
userMessages: Accessor<UserMessage[]>
|
||||
sessionMessages: Accessor<SessionMessageInfo[]>
|
||||
parts: (messageID: string) => Part[]
|
||||
status: Accessor<SessionStatus>
|
||||
showReasoningSummaries: Accessor<boolean>
|
||||
@@ -30,47 +28,19 @@ export function createTimelineProjection(input: {
|
||||
})
|
||||
return result
|
||||
})
|
||||
const activeMessageID = createMemo(() => {
|
||||
const parentID = input
|
||||
.messages()
|
||||
.findLast(
|
||||
(message): message is AssistantMessage =>
|
||||
message.role === "assistant" && typeof message.time.completed !== "number",
|
||||
)?.parentID
|
||||
if (parentID) {
|
||||
const messages = input.messages()
|
||||
const result = Binary.search(messages, parentID, (message) => message.id)
|
||||
const message = result.found ? messages[result.index] : messages.find((item) => item.id === parentID)
|
||||
if (message?.role === "user") return message.id
|
||||
}
|
||||
|
||||
if (input.status().type === "idle") return
|
||||
return input.messages().findLast((message) => message.role === "user")?.id
|
||||
})
|
||||
const messageRowMemos = createMemo(
|
||||
mapArray(input.userMessages, (userMessage, indexAccessor) =>
|
||||
createMemo((previous: TimelineRow.TimelineRow[] | undefined) =>
|
||||
reuseTimelineRows(
|
||||
previous,
|
||||
Timeline.constructMessageRows(
|
||||
userMessage,
|
||||
input.parts,
|
||||
assistantMessagesByParent().get(userMessage.id) ?? emptyAssistantMessages,
|
||||
indexAccessor(),
|
||||
input.showReasoningSummaries(),
|
||||
input.status().type,
|
||||
activeMessageID() === userMessage.id,
|
||||
input.inlineComments(),
|
||||
),
|
||||
),
|
||||
),
|
||||
const projection = createMemo(() =>
|
||||
Timeline.constructSessionMessageRows(
|
||||
input.sessionMessages(),
|
||||
(messageID) => messageByID().get(messageID) as UserMessage | AssistantMessage | undefined,
|
||||
input.parts,
|
||||
input.showReasoningSummaries(),
|
||||
input.status().type,
|
||||
input.inlineComments(),
|
||||
),
|
||||
)
|
||||
const activeMessageID = createMemo(() => projection().activeMessageID)
|
||||
const rows = createMemo((previous: TimelineRow.TimelineRow[] | undefined) =>
|
||||
reuseTimelineRows(
|
||||
previous,
|
||||
messageRowMemos().flatMap((memo) => memo()),
|
||||
),
|
||||
reuseTimelineRows(previous, projection().rows),
|
||||
)
|
||||
const rowByKey = createMemo(() => new Map(rows().map((row) => [TimelineRow.key(row), row] as const)))
|
||||
const messageRowIndex = createMemo(() => {
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { describe, expect, mock, test } from "bun:test"
|
||||
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import { normalizeSessionMessages } from "@/utils/session-message"
|
||||
|
||||
mock.module("@opencode-ai/session-ui/message-part", () => ({
|
||||
renderable: () => true,
|
||||
groupParts: (refs: Array<{ messageID: string; part: { id: string } }>) =>
|
||||
refs.map((ref) => ({
|
||||
type: "part" as const,
|
||||
key: ref.part.id,
|
||||
ref: { messageID: ref.messageID, partID: ref.part.id },
|
||||
})),
|
||||
}))
|
||||
|
||||
const { Timeline, TimelineRow } = await import("./rows")
|
||||
|
||||
describe("current session timeline rows", () => {
|
||||
test("derives turns and tagged rows from chronological current messages", () => {
|
||||
const source = [
|
||||
{ id: "msg_1", type: "user", text: "first", time: { created: 1 } },
|
||||
{
|
||||
id: "msg_2",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: [{ type: "text", text: "answer" }],
|
||||
time: { created: 2, completed: 3 },
|
||||
},
|
||||
{ id: "msg_3", type: "user", text: "second", time: { created: 4 } },
|
||||
{
|
||||
id: "msg_4",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: [{ type: "reasoning", text: "working" }],
|
||||
time: { created: 5 },
|
||||
},
|
||||
] satisfies SessionMessageInfo[]
|
||||
const normalized = normalizeSessionMessages("ses_1", source)
|
||||
const messages = new Map(normalized.messages.map((message) => [message.id, message]))
|
||||
|
||||
const result = Timeline.constructSessionMessageRows(
|
||||
source,
|
||||
(messageID) => messages.get(messageID),
|
||||
(messageID) => normalized.parts.get(messageID) ?? [],
|
||||
true,
|
||||
"busy",
|
||||
true,
|
||||
)
|
||||
|
||||
expect(result.activeMessageID).toBe("msg_3")
|
||||
expect(result.rows.map(TimelineRow.key)).toEqual([
|
||||
"user-message:msg_1",
|
||||
"assistant-part:msg_1:msg_2:text:0",
|
||||
"turn-gap:msg_3",
|
||||
"user-message:msg_3",
|
||||
"assistant-part:msg_3:msg_4:reasoning:0",
|
||||
])
|
||||
})
|
||||
|
||||
test("renders a current shell message as a standalone turn", () => {
|
||||
const source = [
|
||||
{
|
||||
id: "msg_shell",
|
||||
type: "shell",
|
||||
shellID: "shell_1",
|
||||
command: "pwd",
|
||||
status: "exited",
|
||||
exit: 0,
|
||||
output: { output: "/repo", cursor: 5, size: 5, truncated: false },
|
||||
time: { created: 1, completed: 2 },
|
||||
},
|
||||
] satisfies SessionMessageInfo[]
|
||||
const normalized = normalizeSessionMessages("ses_1", source)
|
||||
const messages = new Map(normalized.messages.map((message) => [message.id, message]))
|
||||
|
||||
const result = Timeline.constructSessionMessageRows(
|
||||
source,
|
||||
(messageID) => messages.get(messageID),
|
||||
(messageID) => normalized.parts.get(messageID) ?? [],
|
||||
true,
|
||||
"idle",
|
||||
true,
|
||||
)
|
||||
|
||||
expect(result.activeMessageID).toBe("msg_shell")
|
||||
expect(result.rows.map(TimelineRow.key)).toEqual([
|
||||
"user-message:msg_shell",
|
||||
"assistant-part:msg_shell:msg_shell:tool",
|
||||
])
|
||||
})
|
||||
|
||||
test("associates assistants with a projected parent missing from the source page", () => {
|
||||
const source = [
|
||||
{ id: "msg_user", type: "user", text: "question", time: { created: 1 } },
|
||||
{
|
||||
id: "msg_assistant",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: [{ type: "text", text: "answer" }],
|
||||
time: { created: 2, completed: 3 },
|
||||
},
|
||||
] satisfies SessionMessageInfo[]
|
||||
const normalized = normalizeSessionMessages("ses_1", source)
|
||||
const messages = new Map(normalized.messages.map((message) => [message.id, message]))
|
||||
|
||||
const result = Timeline.constructSessionMessageRows(
|
||||
[source[1]!],
|
||||
(messageID) => messages.get(messageID),
|
||||
(messageID) => normalized.parts.get(messageID) ?? [],
|
||||
true,
|
||||
"idle",
|
||||
true,
|
||||
)
|
||||
|
||||
expect(result.rows.map(TimelineRow.key)).toEqual([
|
||||
"user-message:msg_user",
|
||||
"assistant-part:msg_user:msg_assistant:text:0",
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
|
||||
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import { AssistantMessage, Part, SessionStatus, UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { groupParts, renderable, type PartGroup } from "@opencode-ai/session-ui/message-part"
|
||||
import { TimelineRow, type SummaryDiff } from "./timeline-row"
|
||||
@@ -31,6 +32,55 @@ export type TimelineRowMap = {
|
||||
}
|
||||
|
||||
export namespace Timeline {
|
||||
export function constructSessionMessageRows(
|
||||
messages: SessionMessageInfo[],
|
||||
getMessage: (messageID: string) => UserMessage | AssistantMessage | undefined,
|
||||
getMessageParts: (messageID: string) => Part[],
|
||||
showReasoning: boolean,
|
||||
status: SessionStatus["type"],
|
||||
inlineComments: boolean,
|
||||
) {
|
||||
const turns = messages.flatMap<{ user: UserMessage; assistants: AssistantMessage[] }>((message) => {
|
||||
const projected = getMessage(message.id)
|
||||
if (message.type === "shell" && projected?.role === "user") {
|
||||
const assistant = getMessage(`${message.id}:assistant`)
|
||||
return [{ user: projected, assistants: assistant?.role === "assistant" ? [assistant] : [] }]
|
||||
}
|
||||
return projected?.role === "user" ? [{ user: projected, assistants: [] }] : []
|
||||
})
|
||||
const turnByUserID = new Map(turns.map((turn) => [turn.user.id, turn]))
|
||||
messages.forEach((message) => {
|
||||
const projected = getMessage(message.id)
|
||||
if (projected?.role !== "assistant") return
|
||||
const existing = turnByUserID.get(projected.parentID)
|
||||
if (existing) {
|
||||
existing.assistants.push(projected)
|
||||
return
|
||||
}
|
||||
const user = getMessage(projected.parentID)
|
||||
if (user?.role !== "user") return
|
||||
const turn = { user, assistants: [projected] }
|
||||
turns.push(turn)
|
||||
turnByUserID.set(user.id, turn)
|
||||
})
|
||||
const activeMessageID = turns.at(-1)?.user.id
|
||||
return {
|
||||
activeMessageID,
|
||||
rows: turns.flatMap((turn, index) =>
|
||||
constructMessageRows(
|
||||
turn.user,
|
||||
getMessageParts,
|
||||
turn.assistants,
|
||||
index,
|
||||
showReasoning,
|
||||
status,
|
||||
turn.user.id === activeMessageID,
|
||||
inlineComments,
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export function constructMessageRows(
|
||||
userMessage: UserMessage,
|
||||
getMessageParts: (messageID: string) => Part[],
|
||||
|
||||
@@ -5,7 +5,6 @@ import { previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-b
|
||||
import { useFile, selectionFromLines, type FileSelection, type SelectedLineRange } from "@/context/file"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { usePrompt } from "@/context/prompt"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
@@ -19,6 +18,7 @@ import { extractPromptFromParts } from "@/utils/prompt"
|
||||
import { UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { createSessionOwnership } from "./session-ownership"
|
||||
import { useLocal } from "@/context/local"
|
||||
|
||||
export type SessionCommandContext = {
|
||||
navigateMessageByOffset: (offset: number) => void
|
||||
@@ -40,7 +40,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
const dialog = useDialog()
|
||||
const file = useFile()
|
||||
const language = useLanguage()
|
||||
const local = useLocal()
|
||||
const permission = usePermission()
|
||||
const prompt = usePrompt()
|
||||
const sdk = useSDK()
|
||||
@@ -48,6 +47,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
const sync = useSync()
|
||||
const terminal = useTerminal()
|
||||
const layout = useLayout()
|
||||
const local = useLocal()
|
||||
const navigate = useNavigate()
|
||||
const { params, sessionKey, tabs, view } = useSessionLayout()
|
||||
const sessionOwnership = createSessionOwnership(sessionKey)
|
||||
@@ -306,7 +306,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
const owner = sessionOwnership.capture()
|
||||
const client = sdk().client
|
||||
const session = sdk().api.session
|
||||
const directory = sdk().directory
|
||||
const promptSession = prompt.capture()
|
||||
const revert = info()?.revert?.messageID
|
||||
@@ -316,13 +316,13 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
const parts = sync().data.part[message.id]
|
||||
|
||||
if (sync().data.session_working(sessionID)) {
|
||||
await client.session.abort({ sessionID }).catch(() => {})
|
||||
await session.interrupt({ sessionID }).catch(() => {})
|
||||
}
|
||||
|
||||
await runCommand({
|
||||
owner,
|
||||
prompt: promptSession,
|
||||
request: () => client.session.revert({ sessionID, messageID: message.id }),
|
||||
request: () => session.revert.stage({ sessionID, messageID: message.id }),
|
||||
updatePrompt: (promptSession) => {
|
||||
if (parts) promptSession.set(extractPromptFromParts(parts, { directory }))
|
||||
},
|
||||
@@ -334,7 +334,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
const owner = sessionOwnership.capture()
|
||||
const client = sdk().client
|
||||
const session = sdk().api.session
|
||||
const messages = userMessages()
|
||||
const promptSession = prompt.capture()
|
||||
|
||||
@@ -346,7 +346,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
await runCommand({
|
||||
owner,
|
||||
prompt: promptSession,
|
||||
request: () => client.session.unrevert({ sessionID }),
|
||||
request: () => session.revert.clear({ sessionID }),
|
||||
updatePrompt: (promptSession) => promptSession.reset(),
|
||||
updateViewport: () => setActiveMessage(findLast(messages, (x) => x.id >= revertMessageID)),
|
||||
})
|
||||
@@ -356,7 +356,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
await runCommand({
|
||||
owner,
|
||||
prompt: promptSession,
|
||||
request: () => client.session.revert({ sessionID, messageID: next.id }),
|
||||
request: () => session.revert.stage({ sessionID, messageID: next.id }),
|
||||
updatePrompt: () => undefined,
|
||||
updateViewport: () => setActiveMessage(findLast(messages, (x) => x.id < next.id)),
|
||||
})
|
||||
@@ -375,10 +375,9 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
return
|
||||
}
|
||||
|
||||
await sdk().client.session.summarize({
|
||||
await sdk().api.session.compact({
|
||||
sessionID,
|
||||
modelID: model.id,
|
||||
providerID: model.provider.id,
|
||||
model: { providerID: model.provider.id, modelID: model.id },
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,10 @@ import { describe, expect, test } from "bun:test"
|
||||
import { createApiForServer, createSdkForServer } from "./server"
|
||||
import { createCompatibleApi } from "./server-compat"
|
||||
|
||||
function setup(protocol: "v1" | "v2" | Promise<"v1" | "v2">) {
|
||||
function setup(
|
||||
protocol: "v1" | "v2" | Promise<"v1" | "v2">,
|
||||
responses?: { vcs?: { branch: string; default_branch: string } },
|
||||
) {
|
||||
const requests: Request[] = []
|
||||
const fetcher = Object.assign(
|
||||
async (input: string | URL | Request, init?: RequestInit) => {
|
||||
@@ -32,6 +35,8 @@ function setup(protocol: "v1" | "v2" | Promise<"v1" | "v2">) {
|
||||
delivery: "steer",
|
||||
})
|
||||
}
|
||||
if (request.method === "GET" && new URL(request.url).pathname === "/vcs")
|
||||
return Response.json(responses?.vcs ?? {})
|
||||
if (request.method === "GET") return Response.json([])
|
||||
return new Response(undefined, { status: 204 })
|
||||
},
|
||||
@@ -110,4 +115,35 @@ describe("createCompatibleApi", () => {
|
||||
|
||||
expect(new URL(requests[0]!.url).pathname).toBe("/experimental/session")
|
||||
})
|
||||
|
||||
test("projects the V1 default branch", async () => {
|
||||
const { api } = setup("v1", { vcs: { branch: "feature", default_branch: "dev" } })
|
||||
|
||||
expect(await api.vcs.get({ location: { directory: "/repo" } })).toMatchObject({
|
||||
data: { branch: "feature", defaultBranch: "dev" },
|
||||
})
|
||||
})
|
||||
|
||||
test("translates current file searches to the V1 dirs parameter", async () => {
|
||||
const { api, requests } = setup("v1")
|
||||
await api.file.find({ location: { directory: "/repo" }, query: "src", type: "file", limit: 20 })
|
||||
|
||||
const url = new URL(requests[0]!.url)
|
||||
expect(url.pathname).toBe("/find/file")
|
||||
expect(url.searchParams.get("dirs")).toBe("false")
|
||||
expect(url.searchParams.get("limit")).toBe("20")
|
||||
})
|
||||
|
||||
test("routes V1 permission replies through the requested directory", async () => {
|
||||
const { api, requests } = setup("v1")
|
||||
await api.permission.reply({
|
||||
sessionID: "ses_1",
|
||||
requestID: "permission_1",
|
||||
reply: "once",
|
||||
location: { directory: "/other" },
|
||||
})
|
||||
|
||||
expect(new URL(requests[0]!.url).pathname).toBe("/session/ses_1/permissions/permission_1")
|
||||
expect(new URL(requests[0]!.url).searchParams.get("directory")).toBe("/other")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -30,7 +30,15 @@ type CompatibleSessionApi = Omit<
|
||||
archive: (input: Parameters<SessionApi["archive"]>[0] & LegacyLocation) => ReturnType<SessionApi["archive"]>
|
||||
remove: (input: Parameters<SessionApi["remove"]>[0] & LegacyLocation) => ReturnType<SessionApi["remove"]>
|
||||
}
|
||||
export type CompatibleApi = Omit<ServerApi, "session"> & { readonly session: CompatibleSessionApi }
|
||||
type CompatiblePermissionApi = Omit<ServerApi["permission"], "reply"> & {
|
||||
reply: (
|
||||
input: Parameters<ServerApi["permission"]["reply"]>[0] & { location?: { directory?: string } },
|
||||
) => ReturnType<ServerApi["permission"]["reply"]>
|
||||
}
|
||||
export type CompatibleApi = Omit<ServerApi, "session" | "permission"> & {
|
||||
readonly session: CompatibleSessionApi
|
||||
readonly permission: CompatiblePermissionApi
|
||||
}
|
||||
type LegacyPrompt = {
|
||||
agent?: string
|
||||
model?: { providerID: string; modelID: string }
|
||||
@@ -318,7 +326,7 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
|
||||
...input.current.vcs,
|
||||
async get(value?: Parameters<ServerApi["vcs"]["get"]>[0]) {
|
||||
const result = await legacy(value?.location).vcs.get()
|
||||
return located({ branch: result.data?.branch, defaultBranch: undefined }, value?.location)
|
||||
return located({ branch: result.data?.branch, defaultBranch: result.data?.default_branch }, value?.location)
|
||||
},
|
||||
async status(value?: Parameters<ServerApi["vcs"]["status"]>[0]) {
|
||||
const result = await legacy(value?.location).vcs.status()
|
||||
@@ -350,7 +358,7 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
|
||||
async find(value: Parameters<ServerApi["file"]["find"]>[0]) {
|
||||
const result = await legacy(value.location).find.files({
|
||||
query: value.query,
|
||||
type: value.type,
|
||||
dirs: value.type === undefined ? undefined : value.type === "directory" ? "true" : "false",
|
||||
limit: value.limit,
|
||||
})
|
||||
return located(
|
||||
@@ -471,11 +479,12 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
|
||||
},
|
||||
permission: {
|
||||
...input.current.permission,
|
||||
async reply(value: Parameters<ServerApi["permission"]["reply"]>[0]) {
|
||||
await legacy().permission.respond({
|
||||
async reply(value: Parameters<ServerApi["permission"]["reply"]>[0] & { location?: { directory?: string } }) {
|
||||
await legacy(value.location).permission.respond({
|
||||
sessionID: value.sessionID,
|
||||
permissionID: value.requestID,
|
||||
response: value.reply,
|
||||
directory: directory(value.location),
|
||||
})
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import { normalizeSessionMessages } from "./session-message"
|
||||
|
||||
describe("normalizeSessionMessages", () => {
|
||||
test("projects current turns into stable legacy rendering records", () => {
|
||||
const source = [
|
||||
{ id: "msg_1", type: "agent-switched", agent: "build", time: { created: 1 } },
|
||||
{
|
||||
id: "msg_2",
|
||||
type: "model-switched",
|
||||
model: { id: "claude", providerID: "anthropic", variant: "high" },
|
||||
time: { created: 2 },
|
||||
},
|
||||
{
|
||||
id: "msg_3",
|
||||
type: "user",
|
||||
text: "inspect this",
|
||||
files: [
|
||||
{
|
||||
data: "aGVsbG8=",
|
||||
mime: "text/plain",
|
||||
name: "note.txt",
|
||||
source: { type: "inline" },
|
||||
},
|
||||
],
|
||||
agents: [{ name: "review", mention: { text: "@review", start: 0, end: 7 } }],
|
||||
time: { created: 3 },
|
||||
},
|
||||
{
|
||||
id: "msg_4",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "claude", providerID: "anthropic", variant: "high" },
|
||||
content: [
|
||||
{ type: "reasoning", text: "Thinking", time: { created: 4, completed: 5 } },
|
||||
{ type: "text", text: "Result" },
|
||||
{
|
||||
type: "tool",
|
||||
id: "call_1",
|
||||
name: "read",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { filePath: "note.txt" },
|
||||
structured: { title: "note.txt" },
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
},
|
||||
time: { created: 5, ran: 6, completed: 7 },
|
||||
},
|
||||
],
|
||||
cost: 0.1,
|
||||
tokens: { input: 10, output: 5, reasoning: 2, cache: { read: 1, write: 0 } },
|
||||
time: { created: 4, completed: 7 },
|
||||
},
|
||||
{
|
||||
id: "msg_5",
|
||||
type: "compaction",
|
||||
status: "completed",
|
||||
reason: "auto",
|
||||
summary: "summary",
|
||||
recent: "recent",
|
||||
time: { created: 8 },
|
||||
},
|
||||
] satisfies SessionMessageInfo[]
|
||||
|
||||
const result = normalizeSessionMessages("ses_1", source)
|
||||
|
||||
expect(result.messages).toHaveLength(2)
|
||||
expect(result.messages[0]).toMatchObject({
|
||||
id: "msg_3",
|
||||
role: "user",
|
||||
agent: "build",
|
||||
model: { providerID: "anthropic", modelID: "claude", variant: "high" },
|
||||
})
|
||||
expect(result.messages[1]).toMatchObject({ id: "msg_4", role: "assistant", parentID: "msg_3", cost: 0.1 })
|
||||
expect(result.parts.get("msg_3")?.map((part) => part.id)).toEqual([
|
||||
"msg_3:text:0",
|
||||
"msg_3:file:0",
|
||||
"msg_3:agent:0",
|
||||
"msg_5:compaction",
|
||||
])
|
||||
expect(result.parts.get("msg_4")?.map((part) => part.id)).toEqual(["msg_4:reasoning:0", "msg_4:text:0", "call_1"])
|
||||
expect(result.parts.get("msg_4")?.[2]).toMatchObject({
|
||||
type: "tool",
|
||||
tool: "read",
|
||||
state: { status: "completed", output: "hello" },
|
||||
})
|
||||
})
|
||||
|
||||
test("does not invent a parent for an assistant-only page", () => {
|
||||
const source = [
|
||||
{
|
||||
id: "msg_2",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: [{ type: "text", text: "orphan" }],
|
||||
time: { created: 2 },
|
||||
},
|
||||
] satisfies SessionMessageInfo[]
|
||||
|
||||
expect(normalizeSessionMessages("ses_1", source).messages).toEqual([])
|
||||
})
|
||||
|
||||
test("projects a current shell message into a renderable standalone turn", () => {
|
||||
const source = [
|
||||
{
|
||||
id: "msg_shell",
|
||||
type: "shell",
|
||||
shellID: "shell_1",
|
||||
command: "printf hello",
|
||||
status: "exited",
|
||||
exit: 0,
|
||||
output: { output: "hello", cursor: 5, size: 5, truncated: false },
|
||||
time: { created: 1, completed: 2 },
|
||||
},
|
||||
] satisfies SessionMessageInfo[]
|
||||
|
||||
const result = normalizeSessionMessages("ses_1", source)
|
||||
|
||||
expect(result.messages).toEqual([
|
||||
expect.objectContaining({ id: "msg_shell", role: "user" }),
|
||||
expect.objectContaining({ id: "msg_shell:assistant", role: "assistant", parentID: "msg_shell" }),
|
||||
])
|
||||
expect(result.parts.get("msg_shell")).toEqual([expect.objectContaining({ type: "text", text: "printf hello" })])
|
||||
expect(result.parts.get("msg_shell:assistant")).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "tool",
|
||||
tool: "bash",
|
||||
state: expect.objectContaining({
|
||||
status: "completed",
|
||||
input: { command: "printf hello" },
|
||||
output: "hello",
|
||||
title: "Shell",
|
||||
}),
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
test("adapts current edit fields for the legacy edit renderer", () => {
|
||||
const source = [
|
||||
{ id: "msg_user", type: "user", text: "edit it", time: { created: 1 } },
|
||||
{
|
||||
id: "msg_assistant",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: [
|
||||
{
|
||||
type: "tool",
|
||||
id: "call_edit",
|
||||
name: "edit",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { path: "/repo/README.md", oldString: "old", newString: "new" },
|
||||
content: [{ type: "text", text: "Edited file successfully" }],
|
||||
structured: {
|
||||
files: [
|
||||
{
|
||||
file: "README.md",
|
||||
patch: "@@ -1 +1 @@\n-old\n+new",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
status: "modified",
|
||||
},
|
||||
],
|
||||
replacements: 1,
|
||||
},
|
||||
},
|
||||
time: { created: 2, ran: 3, completed: 4 },
|
||||
},
|
||||
],
|
||||
time: { created: 2, completed: 4 },
|
||||
},
|
||||
] satisfies SessionMessageInfo[]
|
||||
|
||||
const result = normalizeSessionMessages("ses_1", source)
|
||||
|
||||
expect(result.parts.get("msg_assistant")).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "tool",
|
||||
tool: "edit",
|
||||
state: expect.objectContaining({
|
||||
status: "completed",
|
||||
input: expect.objectContaining({ path: "/repo/README.md", filePath: "/repo/README.md" }),
|
||||
metadata: expect.objectContaining({
|
||||
filediff: {
|
||||
file: "README.md",
|
||||
patch: "@@ -1 +1 @@\n-old\n+new",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,348 @@
|
||||
import type {
|
||||
SessionMessageAssistant,
|
||||
SessionMessageAssistantTool,
|
||||
SessionMessageInfo,
|
||||
SessionMessageShell,
|
||||
SessionMessageUser,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import type { AssistantMessage, FilePart, Message, Part, ToolPart, UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { Option, Schema } from "effect"
|
||||
|
||||
const emptyTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
|
||||
const emptyModel: { id: string; providerID: string; variant?: string } = { id: "", providerID: "" }
|
||||
const decodeToolInput = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
|
||||
|
||||
function record(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function normalizeToolInput(name: string, input: Record<string, unknown>) {
|
||||
if (!["edit", "write"].includes(name) || typeof input.path !== "string" || typeof input.filePath === "string")
|
||||
return input
|
||||
return { ...input, filePath: input.path }
|
||||
}
|
||||
|
||||
function normalizeToolMetadata(name: string, metadata: Record<string, unknown>) {
|
||||
if (name !== "edit" || !Array.isArray(metadata.files)) return metadata
|
||||
const file = metadata.files.find(record)
|
||||
if (!file || typeof file.file !== "string") return metadata
|
||||
return {
|
||||
...metadata,
|
||||
filediff: {
|
||||
file: file.file,
|
||||
patch: typeof file.patch === "string" ? file.patch : undefined,
|
||||
additions: typeof file.additions === "number" ? file.additions : 0,
|
||||
deletions: typeof file.deletions === "number" ? file.deletions : 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeSessionMessages(sessionID: string, source: readonly SessionMessageInfo[]) {
|
||||
const messages: Message[] = []
|
||||
const parts = new Map<string, Part[]>()
|
||||
let agent = ""
|
||||
let model = emptyModel
|
||||
let parentID: string | undefined
|
||||
|
||||
source.forEach((message) => {
|
||||
if (message.type === "agent-switched") {
|
||||
agent = message.agent
|
||||
return
|
||||
}
|
||||
if (message.type === "model-switched") {
|
||||
model = message.model
|
||||
return
|
||||
}
|
||||
if (message.type === "user") {
|
||||
parentID = message.id
|
||||
messages.push(userMessage(sessionID, message, agent, model))
|
||||
parts.set(message.id, userParts(sessionID, message))
|
||||
return
|
||||
}
|
||||
if (message.type === "synthetic" && message.description?.trim()) {
|
||||
parentID = message.id
|
||||
messages.push({
|
||||
id: message.id,
|
||||
sessionID,
|
||||
role: "user",
|
||||
time: message.time,
|
||||
agent,
|
||||
model: { providerID: model.providerID, modelID: model.id, variant: model.variant },
|
||||
})
|
||||
parts.set(message.id, [textPart(sessionID, message.id, 0, message.description, true)])
|
||||
return
|
||||
}
|
||||
if (message.type === "shell") {
|
||||
messages.push(...shellMessages(sessionID, message, agent, model))
|
||||
parts.set(message.id, [textPart(sessionID, message.id, 0, message.command)])
|
||||
parts.set(`${message.id}:assistant`, [shellPart(sessionID, message)])
|
||||
parentID = undefined
|
||||
return
|
||||
}
|
||||
if (message.type === "assistant") {
|
||||
agent = message.agent
|
||||
model = message.model
|
||||
if (!parentID) return
|
||||
const parent = messages.findLast((item) => item.id === parentID)
|
||||
if (parent?.role === "user") {
|
||||
parent.agent = message.agent
|
||||
parent.model = {
|
||||
providerID: message.model.providerID,
|
||||
modelID: message.model.id,
|
||||
variant: message.model.variant,
|
||||
}
|
||||
}
|
||||
messages.push(assistantMessage(sessionID, parentID, message))
|
||||
parts.set(message.id, assistantParts(sessionID, message))
|
||||
return
|
||||
}
|
||||
if (message.type !== "compaction" || !parentID) return
|
||||
parts.set(parentID, [
|
||||
...(parts.get(parentID) ?? []),
|
||||
{
|
||||
id: `${message.id}:compaction`,
|
||||
sessionID,
|
||||
messageID: parentID,
|
||||
type: "compaction",
|
||||
auto: message.reason === "auto",
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
return { messages, parts }
|
||||
}
|
||||
|
||||
function shellMessages(
|
||||
sessionID: string,
|
||||
message: SessionMessageShell,
|
||||
agent: string,
|
||||
model: { id: string; providerID: string; variant?: string },
|
||||
): [UserMessage, AssistantMessage] {
|
||||
return [
|
||||
{
|
||||
id: message.id,
|
||||
sessionID,
|
||||
role: "user",
|
||||
time: { created: message.time.created },
|
||||
agent,
|
||||
model: { providerID: model.providerID, modelID: model.id, variant: model.variant },
|
||||
},
|
||||
{
|
||||
id: `${message.id}:assistant`,
|
||||
sessionID,
|
||||
role: "assistant",
|
||||
time: message.time,
|
||||
parentID: message.id,
|
||||
modelID: model.id,
|
||||
providerID: model.providerID,
|
||||
variant: model.variant,
|
||||
mode: agent,
|
||||
agent,
|
||||
path: { cwd: "", root: "" },
|
||||
cost: 0,
|
||||
tokens: emptyTokens,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function shellPart(sessionID: string, message: SessionMessageShell): ToolPart {
|
||||
const input = { command: message.command }
|
||||
const start = message.time.created
|
||||
const state: ToolPart["state"] =
|
||||
message.status === "running"
|
||||
? { status: "running", input, time: { start } }
|
||||
: {
|
||||
status: "completed",
|
||||
input,
|
||||
output: message.output?.output ?? "",
|
||||
title: "Shell",
|
||||
metadata: {
|
||||
status: message.status,
|
||||
exit: message.exit,
|
||||
truncated: message.output?.truncated,
|
||||
},
|
||||
time: { start, end: message.time.completed ?? start },
|
||||
}
|
||||
return {
|
||||
id: `${message.id}:tool`,
|
||||
sessionID,
|
||||
messageID: `${message.id}:assistant`,
|
||||
type: "tool",
|
||||
callID: message.shellID,
|
||||
tool: "bash",
|
||||
state,
|
||||
}
|
||||
}
|
||||
|
||||
export function sessionMessagePartID(messageID: string, type: "text" | "reasoning", ordinal: number) {
|
||||
return `${messageID}:${type}:${ordinal}`
|
||||
}
|
||||
|
||||
function userMessage(
|
||||
sessionID: string,
|
||||
message: SessionMessageUser,
|
||||
agent: string,
|
||||
model: { id: string; providerID: string; variant?: string },
|
||||
): UserMessage {
|
||||
return {
|
||||
id: message.id,
|
||||
sessionID,
|
||||
role: "user",
|
||||
time: message.time,
|
||||
agent,
|
||||
model: { providerID: model.providerID, modelID: model.id, variant: model.variant },
|
||||
}
|
||||
}
|
||||
|
||||
function userParts(sessionID: string, message: SessionMessageUser): Part[] {
|
||||
return [
|
||||
textPart(sessionID, message.id, 0, message.text),
|
||||
...(message.files ?? []).map(
|
||||
(file, index): FilePart => ({
|
||||
id: `${message.id}:file:${index}`,
|
||||
sessionID,
|
||||
messageID: message.id,
|
||||
type: "file",
|
||||
mime: file.mime,
|
||||
filename: file.name,
|
||||
url: file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`,
|
||||
}),
|
||||
),
|
||||
...(message.agents ?? []).map(
|
||||
(item, index): Part => ({
|
||||
id: `${message.id}:agent:${index}`,
|
||||
sessionID,
|
||||
messageID: message.id,
|
||||
type: "agent",
|
||||
name: item.name,
|
||||
source: item.mention
|
||||
? { value: item.mention.text, start: item.mention.start, end: item.mention.end }
|
||||
: undefined,
|
||||
}),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
function assistantMessage(sessionID: string, parentID: string, message: SessionMessageAssistant): AssistantMessage {
|
||||
const error = message.error
|
||||
? message.error.type.toLowerCase().includes("abort") || message.error.type.toLowerCase().includes("interrupt")
|
||||
? { name: "MessageAbortedError" as const, data: { message: message.error.message } }
|
||||
: { name: "UnknownError" as const, data: { message: message.error.message } }
|
||||
: undefined
|
||||
return {
|
||||
id: message.id,
|
||||
sessionID,
|
||||
role: "assistant",
|
||||
time: message.time,
|
||||
error,
|
||||
parentID,
|
||||
modelID: message.model.id,
|
||||
providerID: message.model.providerID,
|
||||
variant: message.model.variant,
|
||||
mode: message.agent,
|
||||
agent: message.agent,
|
||||
path: { cwd: "", root: "" },
|
||||
cost: message.cost ?? 0,
|
||||
tokens: message.tokens ?? emptyTokens,
|
||||
finish: message.finish,
|
||||
}
|
||||
}
|
||||
|
||||
function assistantParts(sessionID: string, message: SessionMessageAssistant): Part[] {
|
||||
const ordinals = { text: 0, reasoning: 0 }
|
||||
return message.content.flatMap((content): Part[] => {
|
||||
if (content.type === "text") {
|
||||
const part = textPart(sessionID, message.id, ordinals.text++, content.text)
|
||||
return content.text.trim() ? [part] : []
|
||||
}
|
||||
if (content.type === "reasoning") {
|
||||
const part: Part = {
|
||||
id: sessionMessagePartID(message.id, "reasoning", ordinals.reasoning++),
|
||||
sessionID,
|
||||
messageID: message.id,
|
||||
type: "reasoning",
|
||||
text: content.text,
|
||||
metadata: content.state,
|
||||
time: {
|
||||
start: content.time?.created ?? message.time.created,
|
||||
end: content.time?.completed,
|
||||
},
|
||||
}
|
||||
return content.text.trim() ? [part] : []
|
||||
}
|
||||
return [toolPart(sessionID, message.id, content)]
|
||||
})
|
||||
}
|
||||
|
||||
function textPart(sessionID: string, messageID: string, ordinal: number, text: string, synthetic?: boolean): Part {
|
||||
return {
|
||||
id: sessionMessagePartID(messageID, "text", ordinal),
|
||||
sessionID,
|
||||
messageID,
|
||||
type: "text",
|
||||
text,
|
||||
synthetic,
|
||||
}
|
||||
}
|
||||
|
||||
function toolPart(sessionID: string, messageID: string, tool: SessionMessageAssistantTool): ToolPart {
|
||||
const start = tool.time.ran ?? tool.time.created
|
||||
const state = (() => {
|
||||
if (tool.state.status === "streaming") {
|
||||
const value = Option.getOrUndefined(decodeToolInput(tool.state.input))
|
||||
const input = normalizeToolInput(tool.name, record(value) ? value : {})
|
||||
return { status: "pending" as const, input, raw: tool.state.input }
|
||||
}
|
||||
if (tool.state.status === "running") {
|
||||
return {
|
||||
status: "running" as const,
|
||||
input: normalizeToolInput(tool.name, tool.state.input),
|
||||
metadata: normalizeToolMetadata(tool.name, tool.state.structured),
|
||||
time: { start },
|
||||
}
|
||||
}
|
||||
if (tool.state.status === "error") {
|
||||
return {
|
||||
status: "error" as const,
|
||||
input: normalizeToolInput(tool.name, tool.state.input),
|
||||
error: tool.state.error.message,
|
||||
metadata: normalizeToolMetadata(tool.name, tool.state.structured),
|
||||
time: { start, end: tool.time.completed ?? start },
|
||||
}
|
||||
}
|
||||
const attachments = tool.state.content.flatMap((item, index): FilePart[] =>
|
||||
item.type === "file"
|
||||
? [
|
||||
{
|
||||
id: `${tool.id}:file:${index}`,
|
||||
sessionID,
|
||||
messageID,
|
||||
type: "file",
|
||||
mime: item.mime,
|
||||
filename: item.name,
|
||||
url: item.uri,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
)
|
||||
return {
|
||||
status: "completed" as const,
|
||||
input: normalizeToolInput(tool.name, tool.state.input),
|
||||
output: tool.state.content.flatMap((item) => (item.type === "text" ? [item.text] : [])).join("\n"),
|
||||
title: tool.name,
|
||||
metadata: normalizeToolMetadata(tool.name, tool.state.structured),
|
||||
time: { start, end: tool.time.completed ?? start },
|
||||
attachments: attachments.length ? attachments : undefined,
|
||||
}
|
||||
})()
|
||||
return {
|
||||
id: tool.id,
|
||||
sessionID,
|
||||
messageID,
|
||||
type: "tool",
|
||||
callID: tool.id,
|
||||
tool: tool.name,
|
||||
state,
|
||||
metadata: { providerState: tool.providerState, providerResultState: tool.providerResultState },
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionApi, SessionInfo, SessionListInput } from "@opencode-ai/client/promise"
|
||||
import { listAllSessions, normalizeSessionInfo } from "./session"
|
||||
|
||||
describe("normalizeSessionInfo", () => {
|
||||
test("adapts a current session to the app session shape", () => {
|
||||
const result = normalizeSessionInfo({
|
||||
id: "session-1",
|
||||
projectID: "project-1",
|
||||
agent: "build",
|
||||
model: { id: "gpt-5", providerID: "openai", variant: "high" },
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1, updated: 1 },
|
||||
title: "New session",
|
||||
location: { directory: "/repo/worktree", workspaceID: "workspace-1" },
|
||||
subpath: "worktree",
|
||||
revert: { messageID: "message-1", partID: "part-1", snapshot: "snapshot", files: [] },
|
||||
} as SessionInfo)
|
||||
|
||||
expect(result).toEqual({
|
||||
id: "session-1",
|
||||
slug: "session-1",
|
||||
projectID: "project-1",
|
||||
workspaceID: "workspace-1",
|
||||
directory: "/repo/worktree",
|
||||
path: "worktree",
|
||||
parentID: undefined,
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
title: "New session",
|
||||
agent: "build",
|
||||
model: { id: "gpt-5", providerID: "openai", variant: "high" },
|
||||
version: "",
|
||||
time: { created: 1, updated: 1 },
|
||||
revert: { messageID: "message-1", partID: "part-1", snapshot: "snapshot" },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("listAllSessions", () => {
|
||||
test("loads every page in server order and retains the query", async () => {
|
||||
const calls: SessionListInput[] = []
|
||||
const pages = new Map<string | undefined, { data: SessionInfo[]; cursor: { next?: string } }>([
|
||||
[undefined, { data: [sessionInfo("session-3"), sessionInfo("session-2")], cursor: { next: "next" } }],
|
||||
["next", { data: [sessionInfo("session-1", true)], cursor: {} }],
|
||||
])
|
||||
const api = {
|
||||
list: async (query = {}) => {
|
||||
calls.push(query)
|
||||
return pages.get(query.cursor) ?? { data: [], cursor: {} }
|
||||
},
|
||||
} satisfies Pick<SessionApi, "list">
|
||||
|
||||
const result = await listAllSessions(api, { directory: "/repo", order: "desc" })
|
||||
|
||||
expect(result.map((session) => session.id)).toEqual(["session-3", "session-2", "session-1"])
|
||||
expect(result[2]?.time.archived).toBe(2)
|
||||
expect(calls).toEqual([
|
||||
{ directory: "/repo", order: "desc", limit: 100, cursor: undefined },
|
||||
{ directory: "/repo", order: "desc", limit: 100, cursor: "next" },
|
||||
])
|
||||
})
|
||||
|
||||
test("requests the terminal empty page when the server returns a next cursor", async () => {
|
||||
const cursors: Array<string | undefined> = []
|
||||
const api = {
|
||||
list: async (query = {}) => {
|
||||
cursors.push(query.cursor)
|
||||
if (query.cursor) return { data: [], cursor: { next: "unused" } }
|
||||
return { data: [sessionInfo("session-1")], cursor: { next: "terminal" } }
|
||||
},
|
||||
} satisfies Pick<SessionApi, "list">
|
||||
|
||||
const result = await listAllSessions(api, { directory: "/repo", limit: 25 })
|
||||
|
||||
expect(result.map((session) => session.id)).toEqual(["session-1"])
|
||||
expect(cursors).toEqual([undefined, "terminal"])
|
||||
})
|
||||
})
|
||||
|
||||
function sessionInfo(id: string, archived = false) {
|
||||
return {
|
||||
id,
|
||||
projectID: "project-1",
|
||||
agent: "build",
|
||||
model: { id: "model-1", providerID: "provider-1" },
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1, updated: 1, archived: archived ? 2 : undefined },
|
||||
title: id,
|
||||
location: { directory: "/repo" },
|
||||
} as SessionInfo
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { SessionApi, SessionInfo, SessionListInput } from "@opencode-ai/client/promise"
|
||||
import type { Session } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
export function normalizeSessionInfo(input: SessionInfo | Session): Session {
|
||||
if (!("location" in input)) return input
|
||||
return {
|
||||
id: input.id,
|
||||
slug: input.id,
|
||||
projectID: input.projectID,
|
||||
workspaceID: input.location.workspaceID,
|
||||
directory: input.location.directory,
|
||||
path: input.subpath,
|
||||
parentID: input.parentID,
|
||||
cost: input.cost,
|
||||
tokens: input.tokens,
|
||||
title: input.title,
|
||||
agent: input.agent,
|
||||
model: input.model,
|
||||
version: "",
|
||||
time: input.time,
|
||||
revert: input.revert && {
|
||||
messageID: input.revert.messageID,
|
||||
partID: input.revert.partID,
|
||||
snapshot: input.revert.snapshot,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function listAllSessions(api: Pick<SessionApi, "list">, input: Omit<SessionListInput, "cursor">) {
|
||||
const load = async (cursor?: string): Promise<Session[]> => {
|
||||
const result = await api.list({ ...input, limit: input.limit ?? 100, cursor })
|
||||
const sessions = result.data.map(normalizeSessionInfo)
|
||||
if (result.data.length === 0 || !result.cursor.next) return sessions
|
||||
return [...sessions, ...(await load(result.cursor.next))]
|
||||
}
|
||||
return load()
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { GlobalSession, Project } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Project } from "@opencode-ai/sdk/v2/client"
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
import { createRoot } from "solid-js"
|
||||
import { createServerSessionEntries } from "@/components/command-palette"
|
||||
import type { LocalProject } from "@/context/layout"
|
||||
@@ -14,15 +15,16 @@ const stored: Project = {
|
||||
time: { created: 1, updated: 1 },
|
||||
}
|
||||
|
||||
const session: GlobalSession = {
|
||||
const session: SessionInfo = {
|
||||
id: "session-1",
|
||||
slug: "session-1",
|
||||
projectID: stored.id,
|
||||
directory: stored.worktree,
|
||||
agent: "build",
|
||||
model: { id: "model-1", providerID: "provider-1" },
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
location: { directory: stored.worktree },
|
||||
title: "Palette session",
|
||||
version: "1",
|
||||
time: { created: 1, updated: 2 },
|
||||
project: { id: stored.id, name: stored.name, worktree: stored.worktree },
|
||||
}
|
||||
|
||||
describe("command palette sessions", () => {
|
||||
|
||||
@@ -521,6 +521,7 @@ export function getToolInfo(
|
||||
}
|
||||
}
|
||||
case "bash":
|
||||
case "shell":
|
||||
return {
|
||||
icon: "console",
|
||||
title: i18n.t("ui.tool.shell"),
|
||||
@@ -538,6 +539,7 @@ export function getToolInfo(
|
||||
title: i18n.t("ui.messagePart.title.write"),
|
||||
subtitle: input.filePath ? getFilename(input.filePath) : undefined,
|
||||
}
|
||||
case "patch":
|
||||
case "apply_patch":
|
||||
return {
|
||||
icon: "code-lines",
|
||||
@@ -729,8 +731,8 @@ export function renderable(part: PartType, showReasoningSummaries = true) {
|
||||
}
|
||||
|
||||
function toolDefaultOpen(tool: string, shell = false, edit = false) {
|
||||
if (tool === "bash") return shell
|
||||
if (tool === "edit" || tool === "write" || tool === "apply_patch") return edit
|
||||
if (tool === "bash" || tool === "shell") return shell
|
||||
if (tool === "edit" || tool === "write" || tool === "patch" || tool === "apply_patch") return edit
|
||||
}
|
||||
|
||||
export function partDefaultOpen(part: PartType, shell = false, edit = false) {
|
||||
@@ -1506,7 +1508,7 @@ export function registerTool(input: { name: string; render?: ToolComponent }) {
|
||||
}
|
||||
|
||||
export function getTool(name: string) {
|
||||
return state[name]?.render
|
||||
return state[name === "apply_patch" ? "patch" : name === "bash" ? "shell" : name]?.render
|
||||
}
|
||||
|
||||
export const ToolRegistry = {
|
||||
@@ -2101,7 +2103,7 @@ ToolRegistry.register({
|
||||
})
|
||||
|
||||
ToolRegistry.register({
|
||||
name: "bash",
|
||||
name: "shell",
|
||||
render(props) {
|
||||
const i18n = useI18n()
|
||||
const pending = () => props.status === "pending" || props.status === "running"
|
||||
@@ -2337,7 +2339,7 @@ ToolRegistry.register({
|
||||
})
|
||||
|
||||
ToolRegistry.register({
|
||||
name: "apply_patch",
|
||||
name: "patch",
|
||||
render(props) {
|
||||
const i18n = useI18n()
|
||||
const fileComponent = useFileComponent()
|
||||
|
||||
@@ -51,6 +51,8 @@ export function ToolErrorCard(props: ToolErrorCardProps) {
|
||||
webfetch: "ui.tool.webfetch",
|
||||
websearch: "ui.tool.websearch",
|
||||
bash: "ui.tool.shell",
|
||||
shell: "ui.tool.shell",
|
||||
patch: "ui.tool.patch",
|
||||
apply_patch: "ui.tool.patch",
|
||||
question: "ui.tool.questions",
|
||||
}
|
||||
|
||||
@@ -109,6 +109,7 @@ OpenCode Zen هي بوابة AI تتيح لك الوصول إلى هذه الن
|
||||
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Ling-3.0-flash Free | ling-3.0-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| North Mini Code Free | north-mini-code-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Nemotron 3 Ultra Free | nemotron-3-ultra-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
@@ -137,6 +138,7 @@ https://opencode.ai/zen/v1/models
|
||||
| DeepSeek V4 Flash Free | Free | Free | Free | - |
|
||||
| MiMo-V2.5 Free | Free | Free | Free | - |
|
||||
| Laguna S 2.1 Free | Free | Free | Free | - |
|
||||
| Ling-3.0-flash Free | Free | Free | Free | - |
|
||||
| North Mini Code Free | Free | Free | Free | - |
|
||||
| Nemotron 3 Ultra Free | Free | Free | Free | - |
|
||||
| MiniMax M3 | $0.30 | $1.20 | $0.06 | - |
|
||||
@@ -210,6 +212,7 @@ https://opencode.ai/zen/v1/models
|
||||
- DeepSeek V4 Flash Free متاح على OpenCode لفترة محدودة. يستخدم الفريق هذه الفترة لجمع الملاحظات وتحسين النموذج.
|
||||
- MiMo-V2.5 Free متاح على OpenCode لفترة محدودة. يستخدم الفريق هذه الفترة لجمع الملاحظات وتحسين النموذج.
|
||||
- Laguna S 2.1 Free متاح على OpenCode لفترة محدودة. يستخدم الفريق هذه الفترة لجمع الملاحظات وتحسين النموذج.
|
||||
- Ling-3.0-flash Free متاح على OpenCode لفترة محدودة. يستخدم الفريق هذه الفترة لجمع الملاحظات وتحسين النموذج.
|
||||
- North Mini Code Free متاح على OpenCode لفترة محدودة. يستخدم الفريق هذه الفترة لجمع الملاحظات وتحسين النموذج.
|
||||
- Nemotron 3 Ultra Free متاح على OpenCode لفترة محدودة. يستخدم الفريق هذه الفترة لجمع الملاحظات وتحسين النموذج.
|
||||
- Big Pickle نموذج خفي ومتاح مجانا على OpenCode لفترة محدودة. يستخدم الفريق هذه الفترة لجمع الملاحظات وتحسين النموذج.
|
||||
@@ -267,6 +270,7 @@ https://opencode.ai/zen/v1/models
|
||||
- DeepSeek V4 Flash Free: خلال فترته المجانية، قد تُستخدم البيانات المجمعة لتحسين النموذج.
|
||||
- MiMo-V2.5 Free: خلال فترته المجانية، قد تُستخدم البيانات المجمعة لتحسين النموذج.
|
||||
- Laguna S 2.1 Free: خلال فترته المجانية، قد تُستخدم البيانات المجمعة لتحسين النموذج.
|
||||
- Ling-3.0-flash Free: خلال فترته المجانية، قد تُستخدم البيانات المجمعة لتحسين النموذج.
|
||||
- North Mini Code Free: خلال فترته المجانية، قد يُحتفَظ بالبيانات المُجمَّعة وتُستخدم لتحسين النموذج. لا تُرسل بيانات شخصية أو سرية. راجع [شروط الاستخدام](https://cohere.com/terms-of-use) و[سياسة الخصوصية](https://cohere.com/privacy).
|
||||
- Nemotron 3 Ultra Free (نقاط نهاية NVIDIA المجانية): للاستخدام التجريبي فقط — لا ترسل بيانات شخصية أو سرية. يُسجَّل استخدامك لأغراض أمنية ولتحسين منتجات وخدمات NVIDIA. بيانات الجلسة المُسجَّلة لأغراض التحسين غير مرتبطة بهويتك أو بأي مُعرِّف دائم. لمزيد من المعلومات حول ممارسات معالجة البيانات لدينا، راجع [سياسة الخصوصية](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). بتفاعلك مع نقطة النهاية هذه، فإنك توافق على جمعنا لهذه المعلومات وتسجيلها واستخدامها وعلى [شروط خدمة النسخة التجريبية من واجهة NVIDIA API](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf).
|
||||
- OpenAI APIs: يتم الاحتفاظ بالطلبات لمدة 30 يوما وفقا لـ [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
|
||||
|
||||
@@ -114,6 +114,7 @@ Našim modelima možete pristupiti i preko sljedećih API endpointa.
|
||||
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Ling-3.0-flash Free | ling-3.0-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| North Mini Code Free | north-mini-code-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Nemotron 3 Ultra Free | nemotron-3-ultra-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
@@ -144,6 +145,7 @@ Podržavamo pay-as-you-go model. Ispod su cijene **po 1M tokena**.
|
||||
| DeepSeek V4 Flash Free | Free | Free | Free | - |
|
||||
| MiMo-V2.5 Free | Free | Free | Free | - |
|
||||
| Laguna S 2.1 Free | Free | Free | Free | - |
|
||||
| Ling-3.0-flash Free | Free | Free | Free | - |
|
||||
| North Mini Code Free | Free | Free | Free | - |
|
||||
| Nemotron 3 Ultra Free | Free | Free | Free | - |
|
||||
| MiniMax M3 | $0.30 | $1.20 | $0.06 | - |
|
||||
@@ -217,6 +219,7 @@ Besplatni modeli:
|
||||
- DeepSeek V4 Flash Free je dostupan na OpenCode ograničeno vrijeme. Tim koristi ovo vrijeme da prikupi povratne informacije i poboljša model.
|
||||
- MiMo-V2.5 Free je dostupan na OpenCode ograničeno vrijeme. Tim koristi ovo vrijeme da prikupi povratne informacije i poboljša model.
|
||||
- Laguna S 2.1 Free je dostupan na OpenCode ograničeno vrijeme. Tim koristi ovo vrijeme da prikupi povratne informacije i poboljša model.
|
||||
- Ling-3.0-flash Free je dostupan na OpenCode ograničeno vrijeme. Tim koristi ovo vrijeme da prikupi povratne informacije i poboljša model.
|
||||
- North Mini Code Free je dostupan na OpenCode ograničeno vrijeme. Tim koristi ovo vrijeme da prikupi povratne informacije i poboljša model.
|
||||
- Nemotron 3 Ultra Free je dostupan na OpenCode ograničeno vrijeme. Tim koristi ovo vrijeme da prikupi povratne informacije i poboljša model.
|
||||
- Big Pickle je stealth model koji je besplatan na OpenCode ograničeno vrijeme. Tim koristi ovo vrijeme da prikupi povratne informacije i poboljša model.
|
||||
@@ -279,6 +282,7 @@ i ne koriste vaše podatke za treniranje modela, uz sljedeće izuzetke:
|
||||
- DeepSeek V4 Flash Free: Tokom besplatnog perioda, prikupljeni podaci mogu se koristiti za poboljšanje modela.
|
||||
- MiMo-V2.5 Free: Tokom besplatnog perioda, prikupljeni podaci mogu se koristiti za poboljšanje modela.
|
||||
- Laguna S 2.1 Free: Tokom besplatnog perioda, prikupljeni podaci mogu se koristiti za poboljšanje modela.
|
||||
- Ling-3.0-flash Free: Tokom besplatnog perioda, prikupljeni podaci mogu se koristiti za poboljšanje modela.
|
||||
- North Mini Code Free: Tokom besplatnog perioda, prikupljeni podaci mogu biti zadržani i korišteni za poboljšanje modela. Nemojte slati lične ili povjerljive podatke. Pogledajte naše [Uslove korištenja](https://cohere.com/terms-of-use) i [Politiku privatnosti](https://cohere.com/privacy).
|
||||
- Nemotron 3 Ultra Free (besplatni NVIDIA endpointi): Samo za probnu upotrebu — nemojte slati lične ili povjerljive podatke. Vaše korištenje se bilježi radi sigurnosti i poboljšanja NVIDIA proizvoda i usluga. Zabilježeni podaci sesije koji se koriste u svrhu poboljšanja nisu povezani s vašim identitetom niti bilo kojim trajnim identifikatorom. Za više informacija o našim praksama obrade podataka pogledajte našu [Politiku privatnosti](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Interakcijom s ovim endpointom pristajete na naše prikupljanje, bilježenje i korištenje takvih informacija te na [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf).
|
||||
- OpenAI APIs: Requests are retained for 30 days in accordance with [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
|
||||
|
||||
@@ -114,6 +114,7 @@ Du kan også få adgang til vores modeller gennem følgende API-endpoints.
|
||||
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Ling-3.0-flash Free | ling-3.0-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| North Mini Code Free | north-mini-code-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Nemotron 3 Ultra Free | nemotron-3-ultra-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
@@ -144,6 +145,7 @@ Vi understøtter en pay-as-you-go-model. Nedenfor er priserne **pr. 1M tokens**.
|
||||
| DeepSeek V4 Flash Free | Free | Free | Free | - |
|
||||
| MiMo-V2.5 Free | Free | Free | Free | - |
|
||||
| Laguna S 2.1 Free | Free | Free | Free | - |
|
||||
| Ling-3.0-flash Free | Free | Free | Free | - |
|
||||
| North Mini Code Free | Free | Free | Free | - |
|
||||
| Nemotron 3 Ultra Free | Free | Free | Free | - |
|
||||
| MiniMax M3 | $0.30 | $1.20 | $0.06 | - |
|
||||
@@ -217,6 +219,7 @@ De gratis modeller:
|
||||
- DeepSeek V4 Flash Free er tilgængelig på OpenCode i en begrænset periode. Teamet bruger denne tid til at indsamle feedback og forbedre modellen.
|
||||
- MiMo-V2.5 Free er tilgængelig på OpenCode i en begrænset periode. Teamet bruger denne tid til at indsamle feedback og forbedre modellen.
|
||||
- Laguna S 2.1 Free er tilgængelig på OpenCode i en begrænset periode. Teamet bruger denne tid til at indsamle feedback og forbedre modellen.
|
||||
- Ling-3.0-flash Free er tilgængelig på OpenCode i en begrænset periode. Teamet bruger denne tid til at indsamle feedback og forbedre modellen.
|
||||
- North Mini Code Free er tilgængelig på OpenCode i en begrænset periode. Teamet bruger denne tid til at indsamle feedback og forbedre modellen.
|
||||
- Nemotron 3 Ultra Free er tilgængelig på OpenCode i en begrænset periode. Teamet bruger denne tid til at indsamle feedback og forbedre modellen.
|
||||
- Big Pickle er en stealth-model, som er gratis på OpenCode i en begrænset periode. Teamet bruger denne tid til at indsamle feedback og forbedre modellen.
|
||||
@@ -277,6 +280,7 @@ Alle vores modeller hostes i US. Vores udbydere følger en nul-opbevaringspoliti
|
||||
- DeepSeek V4 Flash Free: I den gratis periode kan indsamlede data blive brugt til at forbedre modellen.
|
||||
- MiMo-V2.5 Free: I den gratis periode kan indsamlede data blive brugt til at forbedre modellen.
|
||||
- Laguna S 2.1 Free: I den gratis periode kan indsamlede data blive brugt til at forbedre modellen.
|
||||
- Ling-3.0-flash Free: I den gratis periode kan indsamlede data blive brugt til at forbedre modellen.
|
||||
- North Mini Code Free: I gratisperioden kan indsamlede data blive opbevaret og brugt til at forbedre modellen. Indsend ikke personlige eller fortrolige oplysninger. Se vores [Brugsvilkår](https://cohere.com/terms-of-use) og [Privatlivspolitik](https://cohere.com/privacy).
|
||||
- Nemotron 3 Ultra Free (gratis NVIDIA-endpoints): Kun til prøvebrug — indsend ikke personlige eller fortrolige data. Din brug logges af sikkerhedshensyn og for at forbedre NVIDIAs produkter og tjenester. De loggede sessionsdata, der bruges til forbedringsformål, er ikke knyttet til din identitet eller nogen vedvarende identifikator. For mere information om vores databehandlingspraksis, se vores [privatlivspolitik](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Ved at interagere med dette endpoint giver du samtykke til vores indsamling, registrering og brug af sådanne oplysninger samt [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf).
|
||||
- OpenAI APIs: Anmodninger opbevares i 30 dage i overensstemmelse med [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
|
||||
|
||||
@@ -105,6 +105,7 @@ Du kannst auch über die folgenden API-Endpunkte auf unsere Modelle zugreifen.
|
||||
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Ling-3.0-flash Free | ling-3.0-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| North Mini Code Free | north-mini-code-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Nemotron 3 Ultra Free | nemotron-3-ultra-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
@@ -133,6 +134,7 @@ Wir unterstützen ein Pay-as-you-go-Modell. Unten findest du die Preise **pro 1M
|
||||
| DeepSeek V4 Flash Free | Free | Free | Free | - |
|
||||
| MiMo-V2.5 Free | Free | Free | Free | - |
|
||||
| Laguna S 2.1 Free | Free | Free | Free | - |
|
||||
| Ling-3.0-flash Free | Free | Free | Free | - |
|
||||
| North Mini Code Free | Free | Free | Free | - |
|
||||
| Nemotron 3 Ultra Free | Free | Free | Free | - |
|
||||
| MiniMax M3 | $0.30 | $1.20 | $0.06 | - |
|
||||
@@ -206,6 +208,7 @@ Die kostenlosen Modelle:
|
||||
- DeepSeek V4 Flash Free ist für begrenzte Zeit auf OpenCode verfügbar. Das Team nutzt diese Zeit, um Feedback zu sammeln und das Modell zu verbessern.
|
||||
- MiMo-V2.5 Free ist für begrenzte Zeit auf OpenCode verfügbar. Das Team nutzt diese Zeit, um Feedback zu sammeln und das Modell zu verbessern.
|
||||
- Laguna S 2.1 Free ist für begrenzte Zeit auf OpenCode verfügbar. Das Team nutzt diese Zeit, um Feedback zu sammeln und das Modell zu verbessern.
|
||||
- Ling-3.0-flash Free ist für begrenzte Zeit auf OpenCode verfügbar. Das Team nutzt diese Zeit, um Feedback zu sammeln und das Modell zu verbessern.
|
||||
- North Mini Code Free ist für begrenzte Zeit auf OpenCode verfügbar. Das Team nutzt diese Zeit, um Feedback zu sammeln und das Modell zu verbessern.
|
||||
- Nemotron 3 Ultra Free ist für begrenzte Zeit auf OpenCode verfügbar. Das Team nutzt diese Zeit, um Feedback zu sammeln und das Modell zu verbessern.
|
||||
- Big Pickle ist ein Stealth-Modell, das für begrenzte Zeit kostenlos auf OpenCode verfügbar ist. Das Team nutzt diese Zeit, um Feedback zu sammeln und das Modell zu verbessern.
|
||||
@@ -263,6 +266,7 @@ Alle unsere Modelle werden in den USA gehostet. Unsere Provider folgen einer Zer
|
||||
- DeepSeek V4 Flash Free: Während des kostenlosen Zeitraums können gesammelte Daten zur Verbesserung des Modells verwendet werden.
|
||||
- MiMo-V2.5 Free: Während des kostenlosen Zeitraums können gesammelte Daten zur Verbesserung des Modells verwendet werden.
|
||||
- Laguna S 2.1 Free: Während des kostenlosen Zeitraums können gesammelte Daten zur Verbesserung des Modells verwendet werden.
|
||||
- Ling-3.0-flash Free: Während des kostenlosen Zeitraums können gesammelte Daten zur Verbesserung des Modells verwendet werden.
|
||||
- North Mini Code Free: Während des kostenlosen Zeitraums können erhobene Daten gespeichert und zur Verbesserung des Modells verwendet werden. Übermitteln Sie keine personenbezogenen oder vertraulichen Daten. Weitere Informationen finden Sie in unseren [Nutzungsbedingungen](https://cohere.com/terms-of-use) und unserer [Datenschutzerklärung](https://cohere.com/privacy).
|
||||
- Nemotron 3 Ultra Free (kostenlose NVIDIA-Endpunkte): Nur für Testzwecke — übermitteln Sie keine personenbezogenen oder vertraulichen Daten. Ihre Nutzung wird zu Sicherheitszwecken und zur Verbesserung der Produkte und Dienste von NVIDIA protokolliert. Die zu Verbesserungszwecken protokollierten Sitzungsdaten sind nicht mit Ihrer Identität oder einem dauerhaften Identifikator verknüpft. Weitere Informationen zu unseren Datenverarbeitungspraktiken finden Sie in unserer [Datenschutzrichtlinie](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Durch die Interaktion mit diesem Endpunkt stimmen Sie unserer Erhebung, Aufzeichnung und Nutzung solcher Informationen sowie den [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf) zu.
|
||||
- OpenAI APIs: Anfragen werden in Übereinstimmung mit [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data) 30 Tage lang gespeichert.
|
||||
|
||||
@@ -114,6 +114,7 @@ También puedes acceder a nuestros modelos a través de los siguientes endpoints
|
||||
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Ling-3.0-flash Free | ling-3.0-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| North Mini Code Free | north-mini-code-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Nemotron 3 Ultra Free | nemotron-3-ultra-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
@@ -144,6 +145,7 @@ Admitimos un modelo de pago por uso. A continuación se muestran los precios **p
|
||||
| DeepSeek V4 Flash Free | Free | Free | Free | - |
|
||||
| MiMo-V2.5 Free | Free | Free | Free | - |
|
||||
| Laguna S 2.1 Free | Free | Free | Free | - |
|
||||
| Ling-3.0-flash Free | Free | Free | Free | - |
|
||||
| North Mini Code Free | Free | Free | Free | - |
|
||||
| Nemotron 3 Ultra Free | Free | Free | Free | - |
|
||||
| MiniMax M3 | $0.30 | $1.20 | $0.06 | - |
|
||||
@@ -217,6 +219,7 @@ Los modelos gratuitos:
|
||||
- DeepSeek V4 Flash Free está disponible en OpenCode por tiempo limitado. El equipo está usando este tiempo para recopilar comentarios y mejorar el modelo.
|
||||
- MiMo-V2.5 Free está disponible en OpenCode por tiempo limitado. El equipo está usando este tiempo para recopilar comentarios y mejorar el modelo.
|
||||
- Laguna S 2.1 Free está disponible en OpenCode por tiempo limitado. El equipo está usando este tiempo para recopilar comentarios y mejorar el modelo.
|
||||
- Ling-3.0-flash Free está disponible en OpenCode por tiempo limitado. El equipo está usando este tiempo para recopilar comentarios y mejorar el modelo.
|
||||
- North Mini Code Free está disponible en OpenCode por tiempo limitado. El equipo está usando este tiempo para recopilar comentarios y mejorar el modelo.
|
||||
- Nemotron 3 Ultra Free está disponible en OpenCode por tiempo limitado. El equipo está usando este tiempo para recopilar comentarios y mejorar el modelo.
|
||||
- Big Pickle es un modelo stealth que es gratuito en OpenCode por tiempo limitado. El equipo está usando este tiempo para recopilar comentarios y mejorar el modelo.
|
||||
@@ -277,6 +280,7 @@ Todos nuestros modelos están alojados en US. Nuestros proveedores siguen una po
|
||||
- DeepSeek V4 Flash Free: Durante su período gratuito, los datos recopilados pueden usarse para mejorar el modelo.
|
||||
- MiMo-V2.5 Free: Durante su período gratuito, los datos recopilados pueden usarse para mejorar el modelo.
|
||||
- Laguna S 2.1 Free: Durante su período gratuito, los datos recopilados pueden usarse para mejorar el modelo.
|
||||
- Ling-3.0-flash Free: Durante su período gratuito, los datos recopilados pueden usarse para mejorar el modelo.
|
||||
- North Mini Code Free: Durante el período gratuito, los datos recopilados podrán conservarse y utilizarse para mejorar el modelo. No envíes datos personales ni confidenciales. Consulta nuestros [Términos de uso](https://cohere.com/terms-of-use) y nuestra [Política de privacidad](https://cohere.com/privacy).
|
||||
- Nemotron 3 Ultra Free (endpoints gratuitos de NVIDIA): Solo para uso de prueba — no envíes datos personales ni confidenciales. Tu uso se registra con fines de seguridad y para mejorar los productos y servicios de NVIDIA. Los datos de sesión registrados con fines de mejora no están vinculados a tu identidad ni a ningún identificador persistente. Para obtener más información sobre nuestras prácticas de procesamiento de datos, consulta nuestra [Política de privacidad](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Al interactuar con este endpoint, aceptas que recopilemos, registremos y usemos dicha información, así como los [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf).
|
||||
- OpenAI APIs: Las solicitudes se conservan durante 30 días de acuerdo con [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
|
||||
|
||||
@@ -105,6 +105,7 @@ Vous pouvez également accéder à nos modèles via les points de terminaison AP
|
||||
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Ling-3.0-flash Free | ling-3.0-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| North Mini Code Free | north-mini-code-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Nemotron 3 Ultra Free | nemotron-3-ultra-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
@@ -133,6 +134,7 @@ Nous prenons en charge un modèle de paiement à l'utilisation. Vous trouverez c
|
||||
| DeepSeek V4 Flash Free | Free | Free | Free | - |
|
||||
| MiMo-V2.5 Free | Free | Free | Free | - |
|
||||
| Laguna S 2.1 Free | Free | Free | Free | - |
|
||||
| Ling-3.0-flash Free | Free | Free | Free | - |
|
||||
| North Mini Code Free | Free | Free | Free | - |
|
||||
| Nemotron 3 Ultra Free | Free | Free | Free | - |
|
||||
| MiniMax M3 | $0.30 | $1.20 | $0.06 | - |
|
||||
@@ -206,6 +208,7 @@ Les modèles gratuits :
|
||||
- DeepSeek V4 Flash Free est disponible sur OpenCode pour une durée limitée. L'équipe utilise cette période pour recueillir des retours et améliorer le modèle.
|
||||
- MiMo-V2.5 Free est disponible sur OpenCode pour une durée limitée. L'équipe utilise cette période pour recueillir des retours et améliorer le modèle.
|
||||
- Laguna S 2.1 Free est disponible sur OpenCode pour une durée limitée. L'équipe utilise cette période pour recueillir des retours et améliorer le modèle.
|
||||
- Ling-3.0-flash Free est disponible sur OpenCode pour une durée limitée. L'équipe utilise cette période pour recueillir des retours et améliorer le modèle.
|
||||
- North Mini Code Free est disponible sur OpenCode pour une durée limitée. L'équipe utilise cette période pour recueillir des retours et améliorer le modèle.
|
||||
- Nemotron 3 Ultra Free est disponible sur OpenCode pour une durée limitée. L'équipe utilise cette période pour recueillir des retours et améliorer le modèle.
|
||||
- Big Pickle est un modèle stealth gratuit sur OpenCode pour une durée limitée. L'équipe utilise cette période pour recueillir des retours et améliorer le modèle.
|
||||
@@ -263,6 +266,7 @@ Tous nos modèles sont hébergés aux US. Nos fournisseurs suivent une politique
|
||||
- DeepSeek V4 Flash Free : Pendant sa période gratuite, les données collectées peuvent être utilisées pour améliorer le modèle.
|
||||
- MiMo-V2.5 Free : Pendant sa période gratuite, les données collectées peuvent être utilisées pour améliorer le modèle.
|
||||
- Laguna S 2.1 Free : Pendant sa période gratuite, les données collectées peuvent être utilisées pour améliorer le modèle.
|
||||
- Ling-3.0-flash Free : Pendant sa période gratuite, les données collectées peuvent être utilisées pour améliorer le modèle.
|
||||
- North Mini Code Free : Pendant la période de gratuité, les données collectées peuvent être conservées et utilisées pour améliorer le modèle. Ne transmettez aucune donnée personnelle ou confidentielle. Consultez nos [Conditions d’utilisation](https://cohere.com/terms-of-use) et notre [Politique de confidentialité](https://cohere.com/privacy).
|
||||
- Nemotron 3 Ultra Free (endpoints NVIDIA gratuits) : Réservé à un usage d'essai — n'envoyez pas de données personnelles ou confidentielles. Votre utilisation est journalisée à des fins de sécurité et pour améliorer les produits et services de NVIDIA. Les données de session journalisées à des fins d'amélioration ne sont pas liées à votre identité ni à un quelconque identifiant persistant. Pour plus d'informations sur nos pratiques de traitement des données, consultez notre [Politique de confidentialité](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). En interagissant avec cet endpoint, vous consentez à notre collecte, à notre enregistrement et à notre utilisation de ces informations ainsi qu'aux [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf).
|
||||
- OpenAI APIs : Les requêtes sont conservées pendant 30 jours conformément à [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
|
||||
|
||||
@@ -114,6 +114,7 @@ Puoi anche accedere ai nostri modelli tramite i seguenti endpoint API.
|
||||
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Ling-3.0-flash Free | ling-3.0-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| North Mini Code Free | north-mini-code-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Nemotron 3 Ultra Free | nemotron-3-ultra-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
@@ -144,6 +145,7 @@ Supportiamo un modello pay-as-you-go. Qui sotto trovi i prezzi **per 1M token**.
|
||||
| DeepSeek V4 Flash Free | Free | Free | Free | - |
|
||||
| MiMo-V2.5 Free | Free | Free | Free | - |
|
||||
| Laguna S 2.1 Free | Free | Free | Free | - |
|
||||
| Ling-3.0-flash Free | Free | Free | Free | - |
|
||||
| North Mini Code Free | Free | Free | Free | - |
|
||||
| Nemotron 3 Ultra Free | Free | Free | Free | - |
|
||||
| MiniMax M3 | $0.30 | $1.20 | $0.06 | - |
|
||||
@@ -217,6 +219,7 @@ I modelli gratuiti:
|
||||
- DeepSeek V4 Flash Free è disponibile su OpenCode per un periodo limitato. Il team usa questo periodo per raccogliere feedback e migliorare il modello.
|
||||
- MiMo-V2.5 Free è disponibile su OpenCode per un periodo limitato. Il team usa questo periodo per raccogliere feedback e migliorare il modello.
|
||||
- Laguna S 2.1 Free è disponibile su OpenCode per un periodo limitato. Il team usa questo periodo per raccogliere feedback e migliorare il modello.
|
||||
- Ling-3.0-flash Free è disponibile su OpenCode per un periodo limitato. Il team usa questo periodo per raccogliere feedback e migliorare il modello.
|
||||
- North Mini Code Free è disponibile su OpenCode per un periodo limitato. Il team usa questo periodo per raccogliere feedback e migliorare il modello.
|
||||
- Nemotron 3 Ultra Free è disponibile su OpenCode per un periodo limitato. Il team usa questo periodo per raccogliere feedback e migliorare il modello.
|
||||
- Big Pickle è un modello stealth che è gratuito su OpenCode per un periodo limitato. Il team usa questo periodo per raccogliere feedback e migliorare il modello.
|
||||
@@ -277,6 +280,7 @@ Tutti i nostri modelli sono ospitati negli US. I nostri provider seguono una pol
|
||||
- DeepSeek V4 Flash Free: durante il periodo gratuito, i dati raccolti possono essere usati per migliorare il modello.
|
||||
- MiMo-V2.5 Free: durante il periodo gratuito, i dati raccolti possono essere usati per migliorare il modello.
|
||||
- Laguna S 2.1 Free: durante il periodo gratuito, i dati raccolti possono essere usati per migliorare il modello.
|
||||
- Ling-3.0-flash Free: durante il periodo gratuito, i dati raccolti possono essere usati per migliorare il modello.
|
||||
- North Mini Code Free: Durante il periodo gratuito, i dati raccolti possono essere conservati e utilizzati per migliorare il modello. Non inviare dati personali o riservati. Consulta i nostri [Termini di utilizzo](https://cohere.com/terms-of-use) e la nostra [Informativa sulla privacy](https://cohere.com/privacy).
|
||||
- Nemotron 3 Ultra Free (endpoint NVIDIA gratuiti): solo per uso di prova — non inviare dati personali o riservati. Il tuo utilizzo viene registrato per finalità di sicurezza e per migliorare i prodotti e i servizi di NVIDIA. I dati di sessione registrati a fini di miglioramento non sono collegati alla tua identità né ad alcun identificatore persistente. Per maggiori informazioni sulle nostre pratiche di trattamento dei dati, consulta la nostra [Informativa sulla privacy](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Interagendo con questo endpoint, acconsenti alla nostra raccolta, registrazione e utilizzo di tali informazioni e ai [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf).
|
||||
- OpenAI APIs: le richieste vengono conservate per 30 giorni in conformità con [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
|
||||
|
||||
@@ -105,6 +105,7 @@ OpenCode Zen は、OpenCode のほかのプロバイダーと同じように動
|
||||
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Ling-3.0-flash Free | ling-3.0-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| North Mini Code Free | north-mini-code-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Nemotron 3 Ultra Free | nemotron-3-ultra-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
@@ -133,6 +134,7 @@ https://opencode.ai/zen/v1/models
|
||||
| DeepSeek V4 Flash Free | Free | Free | Free | - |
|
||||
| MiMo-V2.5 Free | Free | Free | Free | - |
|
||||
| Laguna S 2.1 Free | Free | Free | Free | - |
|
||||
| Ling-3.0-flash Free | Free | Free | Free | - |
|
||||
| North Mini Code Free | Free | Free | Free | - |
|
||||
| Nemotron 3 Ultra Free | Free | Free | Free | - |
|
||||
| MiniMax M3 | $0.30 | $1.20 | $0.06 | - |
|
||||
@@ -206,6 +208,7 @@ https://opencode.ai/zen/v1/models
|
||||
- DeepSeek V4 Flash Free は期間限定で OpenCode で利用できます。チームはこの期間中にフィードバックを集め、モデルを改善しています。
|
||||
- MiMo-V2.5 Free は期間限定で OpenCode で利用できます。チームはこの期間中にフィードバックを集め、モデルを改善しています。
|
||||
- Laguna S 2.1 Free は期間限定で OpenCode で利用できます。チームはこの期間中にフィードバックを集め、モデルを改善しています。
|
||||
- Ling-3.0-flash Free は期間限定で OpenCode で利用できます。チームはこの期間中にフィードバックを集め、モデルを改善しています。
|
||||
- North Mini Code Free は期間限定で OpenCode で利用できます。チームはこの期間中にフィードバックを集め、モデルを改善しています。
|
||||
- Nemotron 3 Ultra Free は期間限定で OpenCode で利用できます。チームはこの期間中にフィードバックを集め、モデルを改善しています。
|
||||
- Big Pickle はステルスモデルで、期間限定で OpenCode で無料提供されています。チームはこの期間中にフィードバックを集め、モデルを改善しています。
|
||||
@@ -263,6 +266,7 @@ https://opencode.ai/zen/v1/models
|
||||
- DeepSeek V4 Flash Free: 無料提供期間中、収集されたデータがモデル改善に使われる場合があります。
|
||||
- MiMo-V2.5 Free: 無料提供期間中、収集されたデータがモデル改善に使われる場合があります。
|
||||
- Laguna S 2.1 Free: 無料提供期間中、収集されたデータがモデル改善に使われる場合があります。
|
||||
- Ling-3.0-flash Free: 無料提供期間中、収集されたデータがモデル改善に使われる場合があります。
|
||||
- North Mini Code Free: 無料提供期間中、収集されたデータは保持され、モデルの改善に使用される場合があります。個人情報や機密情報を送信しないでください。詳しくは、[利用規約](https://cohere.com/terms-of-use)および[プライバシーポリシー](https://cohere.com/privacy)をご覧ください。
|
||||
- Nemotron 3 Ultra Free(NVIDIA の無料エンドポイント): 試用専用です — 個人情報や機密データは送信しないでください。お客様の利用は、セキュリティ目的および NVIDIA の製品とサービスの改善のために記録されます。改善目的で記録されたセッションデータは、お客様の身元や永続的な識別子とは関連付けられません。当社のデータ処理慣行の詳細については、[プライバシーポリシー](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf)をご覧ください。このエンドポイントを利用することで、お客様はそのような情報の当社による収集、記録、利用、および [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf) に同意したものとみなされます。
|
||||
- OpenAI APIs: リクエストは [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data) に従って 30 日間保持されます。
|
||||
|
||||
@@ -105,6 +105,7 @@ OpenCode Zen은 OpenCode의 다른 provider와 똑같이 작동합니다.
|
||||
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Ling-3.0-flash Free | ling-3.0-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| North Mini Code Free | north-mini-code-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Nemotron 3 Ultra Free | nemotron-3-ultra-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
@@ -133,6 +134,7 @@ https://opencode.ai/zen/v1/models
|
||||
| DeepSeek V4 Flash Free | Free | Free | Free | - |
|
||||
| MiMo-V2.5 Free | Free | Free | Free | - |
|
||||
| Laguna S 2.1 Free | Free | Free | Free | - |
|
||||
| Ling-3.0-flash Free | Free | Free | Free | - |
|
||||
| North Mini Code Free | Free | Free | Free | - |
|
||||
| Nemotron 3 Ultra Free | Free | Free | Free | - |
|
||||
| MiniMax M3 | $0.30 | $1.20 | $0.06 | - |
|
||||
@@ -206,6 +208,7 @@ https://opencode.ai/zen/v1/models
|
||||
- DeepSeek V4 Flash Free는 한정된 기간 동안 OpenCode에서 제공됩니다. 팀은 이 기간에 피드백을 수집하고 모델을 개선합니다.
|
||||
- MiMo-V2.5 Free는 한정된 기간 동안 OpenCode에서 제공됩니다. 팀은 이 기간에 피드백을 수집하고 모델을 개선합니다.
|
||||
- Laguna S 2.1 Free는 한정된 기간 동안 OpenCode에서 제공됩니다. 팀은 이 기간에 피드백을 수집하고 모델을 개선합니다.
|
||||
- Ling-3.0-flash Free는 한정된 기간 동안 OpenCode에서 제공됩니다. 팀은 이 기간에 피드백을 수집하고 모델을 개선합니다.
|
||||
- North Mini Code Free는 한정된 기간 동안 OpenCode에서 제공됩니다. 팀은 이 기간에 피드백을 수집하고 모델을 개선합니다.
|
||||
- Nemotron 3 Ultra Free는 한정된 기간 동안 OpenCode에서 제공됩니다. 팀은 이 기간에 피드백을 수집하고 모델을 개선합니다.
|
||||
- Big Pickle은 한정된 기간 동안 OpenCode에서 무료로 제공되는 stealth model입니다. 팀은 이 기간에 피드백을 수집하고 모델을 개선합니다.
|
||||
@@ -263,6 +266,7 @@ https://opencode.ai/zen/v1/models
|
||||
- DeepSeek V4 Flash Free: 무료 제공 기간에는 수집된 데이터가 모델 개선에 사용될 수 있습니다.
|
||||
- MiMo-V2.5 Free: 무료 제공 기간에는 수집된 데이터가 모델 개선에 사용될 수 있습니다.
|
||||
- Laguna S 2.1 Free: 무료 제공 기간에는 수집된 데이터가 모델 개선에 사용될 수 있습니다.
|
||||
- Ling-3.0-flash Free: 무료 제공 기간에는 수집된 데이터가 모델 개선에 사용될 수 있습니다.
|
||||
- North Mini Code Free: 무료 제공 기간 동안 수집된 데이터는 보관되며 모델 개선에 사용될 수 있습니다. 개인 정보나 기밀 정보를 제출하지 마세요. 자세한 내용은 [이용 약관](https://cohere.com/terms-of-use) 및 [개인정보 처리방침](https://cohere.com/privacy)을 참조하세요.
|
||||
- Nemotron 3 Ultra Free(NVIDIA 무료 엔드포인트): 평가판 전용이며 — 개인 정보나 기밀 데이터는 제출하지 마세요. 사용 내역은 보안 목적과 NVIDIA 제품 및 서비스 개선을 위해 기록됩니다. 개선 목적으로 기록된 세션 데이터는 사용자의 신원이나 영구 식별자와 연결되지 않습니다. 당사의 데이터 처리 관행에 대한 자세한 내용은 [개인정보처리방침](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf)을 참조하세요. 이 엔드포인트와 상호 작용함으로써 사용자는 당사가 이러한 정보를 수집, 기록, 사용하는 것과 [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf)에 동의하게 됩니다.
|
||||
- OpenAI APIs: 요청은 [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data)에 따라 30일 동안 보관됩니다.
|
||||
|
||||
@@ -114,6 +114,7 @@ Du kan også få tilgang til modellene våre gjennom følgende API-endepunkter.
|
||||
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Ling-3.0-flash Free | ling-3.0-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| North Mini Code Free | north-mini-code-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Nemotron 3 Ultra Free | nemotron-3-ultra-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
@@ -144,6 +145,7 @@ Vi støtter en pay-as-you-go-modell. Nedenfor er prisene **per 1M tokens**.
|
||||
| DeepSeek V4 Flash Free | Free | Free | Free | - |
|
||||
| MiMo-V2.5 Free | Free | Free | Free | - |
|
||||
| Laguna S 2.1 Free | Free | Free | Free | - |
|
||||
| Ling-3.0-flash Free | Free | Free | Free | - |
|
||||
| North Mini Code Free | Free | Free | Free | - |
|
||||
| Nemotron 3 Ultra Free | Free | Free | Free | - |
|
||||
| MiniMax M3 | $0.30 | $1.20 | $0.06 | - |
|
||||
@@ -217,6 +219,7 @@ Gratis-modellene:
|
||||
- DeepSeek V4 Flash Free er tilgjengelig på OpenCode i en begrenset periode. Teamet bruker denne tiden til å samle inn tilbakemeldinger og forbedre modellen.
|
||||
- MiMo-V2.5 Free er tilgjengelig på OpenCode i en begrenset periode. Teamet bruker denne tiden til å samle inn tilbakemeldinger og forbedre modellen.
|
||||
- Laguna S 2.1 Free er tilgjengelig på OpenCode i en begrenset periode. Teamet bruker denne tiden til å samle inn tilbakemeldinger og forbedre modellen.
|
||||
- Ling-3.0-flash Free er tilgjengelig på OpenCode i en begrenset periode. Teamet bruker denne tiden til å samle inn tilbakemeldinger og forbedre modellen.
|
||||
- North Mini Code Free er tilgjengelig på OpenCode i en begrenset periode. Teamet bruker denne tiden til å samle inn tilbakemeldinger og forbedre modellen.
|
||||
- Nemotron 3 Ultra Free er tilgjengelig på OpenCode i en begrenset periode. Teamet bruker denne tiden til å samle inn tilbakemeldinger og forbedre modellen.
|
||||
- Big Pickle er en stealth-modell som er gratis på OpenCode i en begrenset periode. Teamet bruker denne tiden til å samle inn tilbakemeldinger og forbedre modellen.
|
||||
@@ -277,6 +280,7 @@ Alle modellene våre hostes i US. Leverandørene våre følger en policy for zer
|
||||
- DeepSeek V4 Flash Free: I gratisperioden kan innsamlede data brukes til å forbedre modellen.
|
||||
- MiMo-V2.5 Free: I gratisperioden kan innsamlede data brukes til å forbedre modellen.
|
||||
- Laguna S 2.1 Free: I gratisperioden kan innsamlede data brukes til å forbedre modellen.
|
||||
- Ling-3.0-flash Free: I gratisperioden kan innsamlede data brukes til å forbedre modellen.
|
||||
- North Mini Code Free: I gratisperioden kan innsamlede data bli oppbevart og brukt til å forbedre modellen. Ikke send inn personopplysninger eller konfidensielle opplysninger. Se våre [Vilkår for bruk](https://cohere.com/terms-of-use) og vår [Personvernerklæring](https://cohere.com/privacy).
|
||||
- Nemotron 3 Ultra Free (gratis NVIDIA-endepunkter): Kun for prøvebruk — ikke send inn personopplysninger eller konfidensielle data. Bruken din logges av sikkerhetshensyn og for å forbedre NVIDIAs produkter og tjenester. Sesjonsdataene som logges for forbedringsformål, er ikke knyttet til identiteten din eller noen vedvarende identifikator. For mer informasjon om vår databehandlingspraksis, se vår [personvernerklæring](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Ved å samhandle med dette endepunktet samtykker du til at vi samler inn, registrerer og bruker slik informasjon, samt til [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf).
|
||||
- OpenAI APIs: Forespørsler lagres i 30 dager i samsvar med [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
|
||||
|
||||
@@ -114,6 +114,7 @@ Możesz też uzyskać dostęp do naszych modeli przez poniższe endpointy API.
|
||||
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Ling-3.0-flash Free | ling-3.0-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| North Mini Code Free | north-mini-code-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Nemotron 3 Ultra Free | nemotron-3-ultra-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
@@ -144,6 +145,7 @@ Obsługujemy model pay-as-you-go. Poniżej znajdują się ceny **za 1M tokenów*
|
||||
| DeepSeek V4 Flash Free | Free | Free | Free | - |
|
||||
| MiMo-V2.5 Free | Free | Free | Free | - |
|
||||
| Laguna S 2.1 Free | Free | Free | Free | - |
|
||||
| Ling-3.0-flash Free | Free | Free | Free | - |
|
||||
| North Mini Code Free | Free | Free | Free | - |
|
||||
| Nemotron 3 Ultra Free | Free | Free | Free | - |
|
||||
| MiniMax M3 | $0.30 | $1.20 | $0.06 | - |
|
||||
@@ -217,6 +219,7 @@ Darmowe modele:
|
||||
- DeepSeek V4 Flash Free jest dostępny w OpenCode przez ograniczony czas. Zespół wykorzystuje ten czas do zbierania opinii i ulepszania modelu.
|
||||
- MiMo-V2.5 Free jest dostępny w OpenCode przez ograniczony czas. Zespół wykorzystuje ten czas do zbierania opinii i ulepszania modelu.
|
||||
- Laguna S 2.1 Free jest dostępny w OpenCode przez ograniczony czas. Zespół wykorzystuje ten czas do zbierania opinii i ulepszania modelu.
|
||||
- Ling-3.0-flash Free jest dostępny w OpenCode przez ograniczony czas. Zespół wykorzystuje ten czas do zbierania opinii i ulepszania modelu.
|
||||
- North Mini Code Free jest dostępny w OpenCode przez ograniczony czas. Zespół wykorzystuje ten czas do zbierania opinii i ulepszania modelu.
|
||||
- Nemotron 3 Ultra Free jest dostępny w OpenCode przez ograniczony czas. Zespół wykorzystuje ten czas do zbierania opinii i ulepszania modelu.
|
||||
- Big Pickle to stealth model, który jest darmowy w OpenCode przez ograniczony czas. Zespół wykorzystuje ten czas do zbierania opinii i ulepszania modelu.
|
||||
@@ -277,6 +280,7 @@ Wszystkie nasze modele są hostowane w US. Nasi dostawcy stosują politykę zero
|
||||
- DeepSeek V4 Flash Free: W czasie darmowego okresu zebrane dane mogą być wykorzystywane do ulepszania modelu.
|
||||
- MiMo-V2.5 Free: W czasie darmowego okresu zebrane dane mogą być wykorzystywane do ulepszania modelu.
|
||||
- Laguna S 2.1 Free: W czasie darmowego okresu zebrane dane mogą być wykorzystywane do ulepszania modelu.
|
||||
- Ling-3.0-flash Free: W czasie darmowego okresu zebrane dane mogą być wykorzystywane do ulepszania modelu.
|
||||
- North Mini Code Free: W okresie bezpłatnego dostępu zebrane dane mogą być przechowywane i wykorzystywane do ulepszania modelu. Nie przesyłaj danych osobowych ani poufnych. Zapoznaj się z naszym [Regulaminem korzystania](https://cohere.com/terms-of-use) i [Polityką prywatności](https://cohere.com/privacy).
|
||||
- Nemotron 3 Ultra Free (darmowe endpointy NVIDIA): Tylko do użytku próbnego — nie przesyłaj danych osobowych ani poufnych. Twoje korzystanie jest rejestrowane w celach bezpieczeństwa oraz w celu ulepszania produktów i usług NVIDIA. Rejestrowane dane sesji wykorzystywane do celów ulepszania nie są powiązane z Twoją tożsamością ani żadnym trwałym identyfikatorem. Aby uzyskać więcej informacji o naszych praktykach przetwarzania danych, zapoznaj się z naszą [Polityką prywatności](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Korzystając z tego endpointu, wyrażasz zgodę na gromadzenie, rejestrowanie i wykorzystywanie przez nas takich informacji oraz na [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf).
|
||||
- OpenAI APIs: Żądania są przechowywane przez 30 dni zgodnie z [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
|
||||
|
||||
@@ -105,6 +105,7 @@ Você também pode acessar nossos modelos pelos seguintes endpoints de API.
|
||||
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Ling-3.0-flash Free | ling-3.0-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| North Mini Code Free | north-mini-code-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Nemotron 3 Ultra Free | nemotron-3-ultra-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
@@ -133,6 +134,7 @@ Oferecemos um modelo pay-as-you-go. Abaixo estão os preços **por 1M tokens**.
|
||||
| DeepSeek V4 Flash Free | Free | Free | Free | - |
|
||||
| MiMo-V2.5 Free | Free | Free | Free | - |
|
||||
| Laguna S 2.1 Free | Free | Free | Free | - |
|
||||
| Ling-3.0-flash Free | Free | Free | Free | - |
|
||||
| North Mini Code Free | Free | Free | Free | - |
|
||||
| Nemotron 3 Ultra Free | Free | Free | Free | - |
|
||||
| MiniMax M3 | $0.30 | $1.20 | $0.06 | - |
|
||||
@@ -206,6 +208,7 @@ Os modelos gratuitos:
|
||||
- DeepSeek V4 Flash Free está disponível no OpenCode por tempo limitado. A equipe está usando esse período para coletar feedback e melhorar o modelo.
|
||||
- MiMo-V2.5 Free está disponível no OpenCode por tempo limitado. A equipe está usando esse período para coletar feedback e melhorar o modelo.
|
||||
- Laguna S 2.1 Free está disponível no OpenCode por tempo limitado. A equipe está usando esse período para coletar feedback e melhorar o modelo.
|
||||
- Ling-3.0-flash Free está disponível no OpenCode por tempo limitado. A equipe está usando esse período para coletar feedback e melhorar o modelo.
|
||||
- North Mini Code Free está disponível no OpenCode por tempo limitado. A equipe está usando esse período para coletar feedback e melhorar o modelo.
|
||||
- Nemotron 3 Ultra Free está disponível no OpenCode por tempo limitado. A equipe está usando esse período para coletar feedback e melhorar o modelo.
|
||||
- Big Pickle é um modelo stealth que está gratuito no OpenCode por tempo limitado. A equipe está usando esse período para coletar feedback e melhorar o modelo.
|
||||
@@ -263,6 +266,7 @@ Todos os nossos modelos são hospedados nos US. Nossos provedores seguem uma pol
|
||||
- DeepSeek V4 Flash Free: Durante seu período gratuito, os dados coletados podem ser usados para melhorar o modelo.
|
||||
- MiMo-V2.5 Free: Durante seu período gratuito, os dados coletados podem ser usados para melhorar o modelo.
|
||||
- Laguna S 2.1 Free: Durante seu período gratuito, os dados coletados podem ser usados para melhorar o modelo.
|
||||
- Ling-3.0-flash Free: Durante seu período gratuito, os dados coletados podem ser usados para melhorar o modelo.
|
||||
- North Mini Code Free: Durante o período gratuito, os dados coletados poderão ser retidos e usados para aprimorar o modelo. Não envie dados pessoais ou confidenciais. Consulte nossos [Termos de Uso](https://cohere.com/terms-of-use) e nossa [Política de Privacidade](https://cohere.com/privacy).
|
||||
- Nemotron 3 Ultra Free (endpoints gratuitos da NVIDIA): Apenas para uso de avaliação — não envie dados pessoais ou confidenciais. Seu uso é registrado para fins de segurança e para melhorar os produtos e serviços da NVIDIA. Os dados de sessão registrados para fins de melhoria não estão vinculados à sua identidade nem a qualquer identificador persistente. Para mais informações sobre nossas práticas de processamento de dados, consulte nossa [Política de Privacidade](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Ao interagir com este endpoint, você consente com a nossa coleta, registro e uso dessas informações e com os [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf).
|
||||
- OpenAI APIs: As solicitações são retidas por 30 dias de acordo com [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
|
||||
|
||||
@@ -114,6 +114,7 @@ OpenCode Zen работает как любой другой провайдер
|
||||
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Ling-3.0-flash Free | ling-3.0-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| North Mini Code Free | north-mini-code-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Nemotron 3 Ultra Free | nemotron-3-ultra-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
@@ -144,6 +145,7 @@ https://opencode.ai/zen/v1/models
|
||||
| DeepSeek V4 Flash Free | Free | Free | Free | - |
|
||||
| MiMo-V2.5 Free | Free | Free | Free | - |
|
||||
| Laguna S 2.1 Free | Free | Free | Free | - |
|
||||
| Ling-3.0-flash Free | Free | Free | Free | - |
|
||||
| North Mini Code Free | Free | Free | Free | - |
|
||||
| Nemotron 3 Ultra Free | Free | Free | Free | - |
|
||||
| MiniMax M3 | $0.30 | $1.20 | $0.06 | - |
|
||||
@@ -217,6 +219,7 @@ https://opencode.ai/zen/v1/models
|
||||
- DeepSeek V4 Flash Free доступна в OpenCode ограниченное время. Команда использует это время, чтобы собирать отзывы и улучшать модель.
|
||||
- MiMo-V2.5 Free доступна в OpenCode ограниченное время. Команда использует это время, чтобы собирать отзывы и улучшать модель.
|
||||
- Laguna S 2.1 Free доступна в OpenCode ограниченное время. Команда использует это время, чтобы собирать отзывы и улучшать модель.
|
||||
- Ling-3.0-flash Free доступна в OpenCode ограниченное время. Команда использует это время, чтобы собирать отзывы и улучшать модель.
|
||||
- North Mini Code Free доступна в OpenCode ограниченное время. Команда использует это время, чтобы собирать отзывы и улучшать модель.
|
||||
- Nemotron 3 Ultra Free доступна в OpenCode ограниченное время. Команда использует это время, чтобы собирать отзывы и улучшать модель.
|
||||
- Big Pickle — это скрытая модель, которая доступна бесплатно в OpenCode ограниченное время. Команда использует это время, чтобы собирать отзывы и улучшать модель.
|
||||
@@ -277,6 +280,7 @@ https://opencode.ai/zen/v1/models
|
||||
- DeepSeek V4 Flash Free: во время бесплатного периода собранные данные могут использоваться для улучшения модели.
|
||||
- MiMo-V2.5 Free: во время бесплатного периода собранные данные могут использоваться для улучшения модели.
|
||||
- Laguna S 2.1 Free: во время бесплатного периода собранные данные могут использоваться для улучшения модели.
|
||||
- Ling-3.0-flash Free: во время бесплатного периода собранные данные могут использоваться для улучшения модели.
|
||||
- North Mini Code Free: В течение бесплатного периода собранные данные могут храниться и использоваться для улучшения модели. Не отправляйте персональные или конфиденциальные данные. Ознакомьтесь с нашими [Условиями использования](https://cohere.com/terms-of-use) и [Политикой конфиденциальности](https://cohere.com/privacy).
|
||||
- Nemotron 3 Ultra Free (бесплатные эндпоинты NVIDIA): только для пробного использования — не отправляйте персональные или конфиденциальные данные. Использование логируется в целях безопасности и для улучшения продуктов и сервисов NVIDIA. Логируемые данные сессии, используемые в целях улучшения, не связаны с вашей личностью или каким-либо постоянным идентификатором. Подробнее о наших практиках обработки данных см. в нашей [Политике конфиденциальности](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Взаимодействуя с этим эндпоинтом, вы соглашаетесь на сбор, запись и использование нами такой информации, а также с [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf).
|
||||
- OpenAI APIs: запросы хранятся 30 дней в соответствии с [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user