mirror of
https://github.com/sst/opencode.git
synced 2025-07-07 16:14:59 +00:00
Merge 83098bb705
into d87922c0eb
This commit is contained in:
commit
ac25e5de5c
13 changed files with 1163 additions and 1 deletions
|
@ -460,6 +460,109 @@ export namespace Server {
|
|||
return c.json(msg)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/session/:id/export",
|
||||
describeRoute({
|
||||
description: "Export session to local storage",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successfully exported session",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
z.object({
|
||||
localUrl: z.string(),
|
||||
exportPath: z.string(),
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
zValidator(
|
||||
"json",
|
||||
z.object({
|
||||
sessionID: z.string(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const body = c.req.valid("json")
|
||||
const result = await Session.exportLocal(body.sessionID)
|
||||
return c.json(result)
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/session/export",
|
||||
describeRoute({
|
||||
description: "List all exported sessions",
|
||||
responses: {
|
||||
200: {
|
||||
description: "List of exported sessions",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
z.array(
|
||||
Session.Info.extend({
|
||||
exportedAt: z.number().optional(),
|
||||
exportPath: z.string().optional(),
|
||||
}),
|
||||
),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const exportedSessions = await Session.listExported()
|
||||
return c.json(exportedSessions)
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/session/:id/export",
|
||||
describeRoute({
|
||||
description: "Get exported session data",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Exported session data",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
z.object({
|
||||
session: Session.Info,
|
||||
messages: z.array(Message.Info),
|
||||
exportedAt: z.number(),
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
404: {
|
||||
description: "Exported session not found",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.object({ error: z.string() })),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
zValidator(
|
||||
"json",
|
||||
z.object({
|
||||
sessionID: z.string(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const body = c.req.valid("json")
|
||||
const exportedData = await Session.getExported(body.sessionID)
|
||||
if (!exportedData) {
|
||||
return c.json({ error: "Exported session not found" }, 404)
|
||||
}
|
||||
return c.json(exportedData)
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/config/providers",
|
||||
describeRoute({
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import path from "node:path"
|
||||
import fs from "fs/promises"
|
||||
import { Decimal } from "decimal.js"
|
||||
import { z, ZodSchema } from "zod"
|
||||
import {
|
||||
|
@ -37,6 +38,9 @@ import { NamedError } from "../util/error"
|
|||
import { Message } from "./message"
|
||||
import { SystemPrompt } from "./system"
|
||||
import { FileTime } from "../file/time"
|
||||
import { Global } from "../global"
|
||||
|
||||
const WEB_SERVER_PORT = 4321
|
||||
|
||||
export namespace Session {
|
||||
const log = Log.create({ service: "session" })
|
||||
|
@ -196,6 +200,88 @@ export namespace Session {
|
|||
await Share.remove(id, share.secret)
|
||||
}
|
||||
|
||||
function getExportsDir() {
|
||||
return path.join(Global.Path.config, "session-exports")
|
||||
}
|
||||
|
||||
export async function exportLocal(id: string) {
|
||||
const session = await get(id)
|
||||
if (!session) throw new Error(`Session ${id} not found`)
|
||||
|
||||
const messages = await Session.messages(id)
|
||||
|
||||
// Create local shares directory in config
|
||||
const exportsDir = getExportsDir()
|
||||
await fs.mkdir(exportsDir, { recursive: true })
|
||||
|
||||
// Export session info
|
||||
const sessionPath = path.join(exportsDir, `${id}.json`)
|
||||
await Bun.write(
|
||||
sessionPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
session,
|
||||
messages,
|
||||
exportedAt: Date.now(),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
|
||||
log.info("Session exported locally", { sessionId: id, path: sessionPath })
|
||||
|
||||
const localUrl = `http://localhost:${WEB_SERVER_PORT}/local/${id}`
|
||||
|
||||
return {
|
||||
localUrl,
|
||||
exportPath: sessionPath,
|
||||
}
|
||||
}
|
||||
|
||||
export async function listExported() {
|
||||
try {
|
||||
const exportsDir = getExportsDir()
|
||||
const files = await fs.readdir(exportsDir)
|
||||
const exportedSessions = []
|
||||
|
||||
for (const file of files) {
|
||||
if (file.endsWith(".json")) {
|
||||
const filePath = path.join(exportsDir, file)
|
||||
try {
|
||||
const exportData = JSON.parse(await Bun.file(filePath).text())
|
||||
if (exportData.session && exportData.messages) {
|
||||
exportedSessions.push(exportData)
|
||||
}
|
||||
} catch (error) {
|
||||
log.warn("Failed to parse exported session", { file, error })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return exportedSessions
|
||||
} catch (error) {
|
||||
// Directory doesn't exist or other error
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function getExported(id: string) {
|
||||
const exportsDir = getExportsDir()
|
||||
const filePath = path.join(exportsDir, `${id}.json`)
|
||||
|
||||
try {
|
||||
const exportData = JSON.parse(await Bun.file(filePath).text())
|
||||
if (exportData.session && exportData.messages) {
|
||||
return exportData
|
||||
}
|
||||
return null
|
||||
} catch (error) {
|
||||
log.warn("Failed to get exported session", { id, error })
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function update(id: string, editor: (session: Info) => void) {
|
||||
const { sessions } = state()
|
||||
const session = await get(id)
|
||||
|
|
|
@ -76,6 +76,7 @@ const (
|
|||
SessionListCommand CommandName = "session_list"
|
||||
SessionShareCommand CommandName = "session_share"
|
||||
SessionUnshareCommand CommandName = "session_unshare"
|
||||
SessionExportCommand CommandName = "session_export"
|
||||
SessionInterruptCommand CommandName = "session_interrupt"
|
||||
SessionCompactCommand CommandName = "session_compact"
|
||||
ToolDetailsCommand CommandName = "tool_details"
|
||||
|
@ -167,6 +168,11 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
|
|||
Keybindings: parseBindings("<leader>u"),
|
||||
Trigger: "unshare",
|
||||
},
|
||||
{
|
||||
Name: SessionExportCommand,
|
||||
Description: "export session locally",
|
||||
Trigger: "export",
|
||||
},
|
||||
{
|
||||
Name: SessionInterruptCommand,
|
||||
Description: "interrupt session",
|
||||
|
|
|
@ -1,8 +1,11 @@
|
|||
package tui
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
@ -841,6 +844,29 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
|
|||
}
|
||||
a.app.Session.Share.URL = ""
|
||||
cmds = append(cmds, toast.NewSuccessToast("Session unshared successfully"))
|
||||
|
||||
case commands.SessionExportCommand:
|
||||
if a.app.Session.ID == "" {
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// TODO: Need to regenerate client SDK to support this
|
||||
// response, err := a.app.Client.Session.Export(
|
||||
// context.Background(),
|
||||
// a.app.Session.ID,
|
||||
// )
|
||||
|
||||
// Temporary HTTP call until SDK is regenerated
|
||||
response, err := httpExportSession(a.app.Session.ID)
|
||||
if err != nil {
|
||||
slog.Error("Failed to export session locally", "error", err)
|
||||
return a, toast.NewErrorToast("Failed to export session locally")
|
||||
}
|
||||
if response.JSON200 != nil {
|
||||
localUrl := response.JSON200.LocalUrl
|
||||
cmds = append(cmds, tea.SetClipboard(localUrl))
|
||||
cmds = append(cmds, toast.NewSuccessToast("Local URL copied to clipboard!"))
|
||||
}
|
||||
case commands.SessionInterruptCommand:
|
||||
if a.app.Session.ID == "" {
|
||||
return a, nil
|
||||
|
@ -974,6 +1000,49 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
|
|||
return a, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
// TODO: Remove this helper function when SDK is regenerated with export support
|
||||
func httpExportSession(sessionID string) (*struct {
|
||||
JSON200 *struct {
|
||||
LocalUrl string
|
||||
}
|
||||
}, error) {
|
||||
client := http.Client{Timeout: 10 * time.Second}
|
||||
reqBody := map[string]string{"sessionID": sessionID}
|
||||
jsonBody, _ := json.Marshal(reqBody)
|
||||
|
||||
resp, err := client.Post(
|
||||
"http://localhost:4096/session/"+sessionID+"/export",
|
||||
"application/json",
|
||||
bytes.NewBuffer(jsonBody),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result struct {
|
||||
LocalUrl string `json:"localUrl"`
|
||||
ExportPath string `json:"exportPath"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &struct {
|
||||
JSON200 *struct {
|
||||
LocalUrl string
|
||||
}
|
||||
}{
|
||||
JSON200: &struct {
|
||||
LocalUrl string
|
||||
}{LocalUrl: result.LocalUrl},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func NewModel(app *app.App) tea.Model {
|
||||
commandProvider := completions.NewCommandCompletionProvider(app)
|
||||
fileProvider := completions.NewFileAndFolderContextGroup(app)
|
||||
|
|
264
packages/web/src/components/SessionsList.tsx
Normal file
264
packages/web/src/components/SessionsList.tsx
Normal file
|
@ -0,0 +1,264 @@
|
|||
import { Show, For } from "solid-js"
|
||||
|
||||
export interface SessionData {
|
||||
id: string
|
||||
title?: string
|
||||
time: {
|
||||
created: number
|
||||
updated: number
|
||||
}
|
||||
version?: string
|
||||
exportedAt?: number
|
||||
computedData: {
|
||||
rootDir?: string
|
||||
created: number
|
||||
completed?: number
|
||||
models: Record<string, string[]>
|
||||
cost: number
|
||||
tokens: {
|
||||
input: number
|
||||
output: number
|
||||
reasoning: number
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface SessionsListProps {
|
||||
sessions: SessionData[]
|
||||
title: string
|
||||
emptyMessage: string
|
||||
helpText?: string
|
||||
error?: string | null
|
||||
apiUrl?: string
|
||||
basePath?: string
|
||||
}
|
||||
|
||||
export default function SessionsList(props: SessionsListProps) {
|
||||
return (
|
||||
<div class="local-sessions">
|
||||
<h1>{props.title}</h1>
|
||||
|
||||
<Show when={props.error}>
|
||||
<div class="error-message">
|
||||
<strong>Error:</strong> {props.error}
|
||||
<p>Make sure opencode serve is running on {props.apiUrl}</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!props.error}>
|
||||
<div>
|
||||
<Show when={props.sessions.length === 0}>
|
||||
<p class="empty-state">{props.emptyMessage}</p>
|
||||
</Show>
|
||||
|
||||
<Show when={props.sessions.length > 0}>
|
||||
<div class="sessions-list">
|
||||
<For each={props.sessions}>
|
||||
{(session) => (
|
||||
<div class="session-item">
|
||||
<div class="session-title">
|
||||
<h3>
|
||||
<a
|
||||
href={`${props.basePath}/${session.id}`}
|
||||
class="session-link"
|
||||
>
|
||||
{session.title?.trim() || "(no title)"}
|
||||
</a>
|
||||
</h3>
|
||||
</div>
|
||||
<div data-section="row">
|
||||
<ul data-section="stats">
|
||||
<li>
|
||||
<span data-element-label>Cost</span>
|
||||
<Show
|
||||
when={session.computedData.cost !== undefined}
|
||||
fallback={<span data-placeholder>—</span>}
|
||||
>
|
||||
<span>${session.computedData.cost.toFixed(2)}</span>
|
||||
</Show>
|
||||
</li>
|
||||
<li>
|
||||
<span data-element-label>Input Tokens</span>
|
||||
<Show
|
||||
when={session.computedData.tokens.input}
|
||||
fallback={<span data-placeholder>—</span>}
|
||||
>
|
||||
<span>{session.computedData.tokens.input}</span>
|
||||
</Show>
|
||||
</li>
|
||||
<li>
|
||||
<span data-element-label>Output Tokens</span>
|
||||
<Show
|
||||
when={session.computedData.tokens.output}
|
||||
fallback={<span data-placeholder>—</span>}
|
||||
>
|
||||
<span>{session.computedData.tokens.output}</span>
|
||||
</Show>
|
||||
</li>
|
||||
<li>
|
||||
<span data-element-label>Reasoning Tokens</span>
|
||||
<Show
|
||||
when={session.computedData.tokens.reasoning}
|
||||
fallback={<span data-placeholder>—</span>}
|
||||
>
|
||||
<span>{session.computedData.tokens.reasoning}</span>
|
||||
</Show>
|
||||
</li>
|
||||
</ul>
|
||||
<Show when={session.computedData.rootDir}>
|
||||
<ul data-section="stats" data-section-root>
|
||||
<li title="Project root">
|
||||
<div data-stat-icon>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.11.89 2 2 2h16c1.11 0 2-.89 2-2V8c0-1.11-.89-2-2-2h-8l-2-2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span>{session.computedData.rootDir}</span>
|
||||
</li>
|
||||
<li title="opencode version">
|
||||
<div data-stat-icon title="opencode">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
|
||||
</svg>
|
||||
</div>
|
||||
<span>v{session.version || "0.0.1"}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</Show>
|
||||
<Show when={!session.computedData.rootDir}>
|
||||
<ul data-section="stats" data-section-root>
|
||||
<li title="opencode version">
|
||||
<div data-stat-icon title="opencode">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
|
||||
</svg>
|
||||
</div>
|
||||
<span>v{session.version || "0.0.1"}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</Show>
|
||||
<ul data-section="stats" data-section-models>
|
||||
<Show
|
||||
when={
|
||||
Object.values(session.computedData.models).length >
|
||||
0
|
||||
}
|
||||
fallback={
|
||||
<li>
|
||||
<span data-element-label>Models</span>
|
||||
<span data-placeholder>—</span>
|
||||
</li>
|
||||
}
|
||||
>
|
||||
<For
|
||||
each={Object.values(session.computedData.models)}
|
||||
>
|
||||
{(item) => (
|
||||
<li>
|
||||
<div data-stat-icon title={item[0]}>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M9.5,3A6.5,6.5 0 0,1 16,9.5C16,11.11 15.41,12.59 14.44,13.73L14.71,14H15.5L20.5,19L19,20.5L14,15.5V14.71L13.73,14.44C12.59,15.41 11.11,16 9.5,16A6.5,6.5 0 0,1 3,9.5A6.5,6.5 0 0,1 9.5,3M9.5,5C7,5 5,7 5,9.5C5,12 7,14 9.5,14C12,14 14,12 14,9.5C14,7 12,5 9.5,5Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span data-stat-model>{item[1]}</span>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</ul>
|
||||
<div data-section="time">
|
||||
<Show
|
||||
when={session.computedData.created}
|
||||
fallback={
|
||||
<span data-element-label data-placeholder>
|
||||
Started at —
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<span
|
||||
title={new Date(
|
||||
session.computedData.created,
|
||||
).toLocaleString()}
|
||||
>
|
||||
Started{" "}
|
||||
{new Date(
|
||||
session.computedData.created,
|
||||
).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}) +
|
||||
", " +
|
||||
new Date(
|
||||
session.computedData.created,
|
||||
).toLocaleTimeString("en-US", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={session.exportedAt}>
|
||||
<span
|
||||
title={new Date(
|
||||
session.exportedAt!,
|
||||
).toLocaleString()}
|
||||
style="font-size: 0.9em; color: #666;"
|
||||
>
|
||||
Exported{" "}
|
||||
{new Date(session.exportedAt!).toLocaleDateString(
|
||||
"en-US",
|
||||
{
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
},
|
||||
) +
|
||||
", " +
|
||||
new Date(session.exportedAt!).toLocaleTimeString(
|
||||
"en-US",
|
||||
{
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
},
|
||||
)}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.helpText}>
|
||||
<div class="help-section">
|
||||
<h3>How to use:</h3>
|
||||
<div innerHTML={props.helpText} />
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -67,7 +67,9 @@ interface Todo {
|
|||
priority: "low" | "medium" | "high"
|
||||
}
|
||||
|
||||
function sortTodosByStatus(todos: Todo[]) {
|
||||
function sortTodosByStatus(todos: Todo[] | undefined) {
|
||||
if (!todos) return []
|
||||
|
||||
const statusPriority: Record<TodoStatus, number> = {
|
||||
in_progress: 0,
|
||||
pending: 1,
|
||||
|
|
97
packages/web/src/lib/local-session-utils.ts
Normal file
97
packages/web/src/lib/local-session-utils.ts
Normal file
|
@ -0,0 +1,97 @@
|
|||
export interface SessionData {
|
||||
rootDir: string | undefined
|
||||
created: number
|
||||
completed: number | undefined
|
||||
models: Record<string, string[]>
|
||||
cost: number
|
||||
tokens: {
|
||||
input: number
|
||||
output: number
|
||||
reasoning: number
|
||||
}
|
||||
}
|
||||
|
||||
export function computeSessionData(
|
||||
sessionInfo: any,
|
||||
messages: any[],
|
||||
): SessionData {
|
||||
const result: SessionData = {
|
||||
rootDir: undefined,
|
||||
created: sessionInfo.time.created,
|
||||
completed: undefined,
|
||||
models: {},
|
||||
cost: 0,
|
||||
tokens: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
reasoning: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for (const msg of messages) {
|
||||
const assistant = msg.metadata?.assistant
|
||||
if (assistant) {
|
||||
result.cost += assistant.cost || 0
|
||||
result.tokens.input += assistant.tokens?.input || 0
|
||||
result.tokens.output += assistant.tokens?.output || 0
|
||||
result.tokens.reasoning += assistant.tokens?.reasoning || 0
|
||||
|
||||
if (assistant.providerID && assistant.modelID) {
|
||||
result.models[`${assistant.providerID} ${assistant.modelID}`] = [
|
||||
assistant.providerID,
|
||||
assistant.modelID,
|
||||
]
|
||||
}
|
||||
|
||||
if (assistant.path?.root) {
|
||||
result.rootDir = assistant.path.root
|
||||
}
|
||||
|
||||
if (msg.metadata?.time?.completed) {
|
||||
result.completed = msg.metadata.time.completed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export async function fetchProjectSessions(localApiUrl: string) {
|
||||
const response = await fetch(`${localApiUrl}/session`, {
|
||||
method: "GET",
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch sessions")
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
export async function fetchSessionMessages(
|
||||
localApiUrl: string,
|
||||
sessionId: string,
|
||||
) {
|
||||
const response = await fetch(`${localApiUrl}/session/${sessionId}/message`, {
|
||||
method: "get",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch session messages")
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
export async function fetchExportedSessions(localApiUrl: string) {
|
||||
const response = await fetch(`${localApiUrl}/session/export`, {
|
||||
method: "GET",
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
return []
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
}
|
93
packages/web/src/pages/local/[sessionId].astro
Normal file
93
packages/web/src/pages/local/[sessionId].astro
Normal file
|
@ -0,0 +1,93 @@
|
|||
---
|
||||
import StarlightPage from '@astrojs/starlight/components/StarlightPage.astro';
|
||||
import Share from "../../components/Share.tsx";
|
||||
import { fetchExportedSessions, fetchSessionMessages } from "../../lib/local-session-utils";
|
||||
import type { Session } from "@opencode/opencode/session";
|
||||
import type { Message } from "@opencode/opencode/session/message";
|
||||
|
||||
interface ExportedSession {
|
||||
session: Session.Info;
|
||||
messages: Message.Info[];
|
||||
exportedAt: number;
|
||||
}
|
||||
|
||||
const localApiUrl = import.meta.env.VITE_LOCAL_API_URL || "http://localhost:4096";
|
||||
const { sessionId } = Astro.params;
|
||||
|
||||
let sessionInfo;
|
||||
let messages;
|
||||
let error = null;
|
||||
|
||||
try {
|
||||
const sessions = await fetchExportedSessions(localApiUrl);
|
||||
|
||||
// Find session from exported sessions structure
|
||||
const exportedSession = sessions.find((exportData: ExportedSession) =>
|
||||
exportData.session.id === sessionId
|
||||
);
|
||||
|
||||
sessionInfo = exportedSession?.session;
|
||||
messages = exportedSession?.messages;
|
||||
|
||||
if (!sessionInfo) {
|
||||
return new Response(null, {
|
||||
status: 404,
|
||||
statusText: 'Exported session not found'
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return new Response(null, {
|
||||
status: 500,
|
||||
statusText: error
|
||||
});
|
||||
}
|
||||
|
||||
const messagesObj = {};
|
||||
messages.forEach((msg: any) => {
|
||||
messagesObj[msg.id] = msg;
|
||||
});
|
||||
|
||||
---
|
||||
<StarlightPage
|
||||
hasSidebar={false}
|
||||
frontmatter={{
|
||||
title: sessionInfo.title,
|
||||
pagefind: false,
|
||||
template: "splash",
|
||||
tableOfContents: false,
|
||||
head: [
|
||||
{
|
||||
tag: "meta",
|
||||
attrs: {
|
||||
name: "description",
|
||||
content: "opencode - Exported Session Viewer",
|
||||
},
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<Share
|
||||
id={sessionId}
|
||||
api={localApiUrl}
|
||||
info={sessionInfo}
|
||||
messages={messagesObj}
|
||||
client:only="solid"
|
||||
/>
|
||||
</StarlightPage>
|
||||
|
||||
<style is:global>
|
||||
body > .page > .main-frame .main-pane > main > .content-panel:first-of-type {
|
||||
display: none;
|
||||
}
|
||||
body > .page > .main-frame .main-pane > main {
|
||||
padding: 0;
|
||||
}
|
||||
body > .page > .main-frame .main-pane > main > .content-panel + .content-panel {
|
||||
border-top: none !important;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
86
packages/web/src/pages/local/index.astro
Normal file
86
packages/web/src/pages/local/index.astro
Normal file
|
@ -0,0 +1,86 @@
|
|||
---
|
||||
import StarlightPage from '@astrojs/starlight/components/StarlightPage.astro';
|
||||
import SessionsList from "../../components/SessionsList.tsx";
|
||||
import { fetchExportedSessions, computeSessionData } from "../../lib/local-session-utils";
|
||||
import "../../styles/sessions.css";
|
||||
|
||||
const localApiUrl = import.meta.env.VITE_LOCAL_API_URL || "http://localhost:4096";
|
||||
|
||||
let sessions = [];
|
||||
let error = null;
|
||||
|
||||
try {
|
||||
const exportedSessionList = await fetchExportedSessions(localApiUrl);
|
||||
|
||||
const exportedSessionsWithData = exportedSessionList.map((exportData: any) => {
|
||||
const session = exportData.session || exportData;
|
||||
const messages = exportData.messages || [];
|
||||
const computedData = computeSessionData(session, messages);
|
||||
return {
|
||||
...session,
|
||||
computedData,
|
||||
exportedAt: exportData.exportedAt
|
||||
};
|
||||
});
|
||||
|
||||
sessions = exportedSessionsWithData.sort((a: any, b: any) => b.time.updated - a.time.updated);
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
}
|
||||
|
||||
let storageLocation = 'config directory';
|
||||
try {
|
||||
if (sessions.length > 0 && sessions[0].share?.url) {
|
||||
const shareUrl = sessions[0].share.url;
|
||||
if (shareUrl.startsWith('file://')) {
|
||||
const filePath = shareUrl.replace('file://', '');
|
||||
const configDir = filePath.substring(0, filePath.lastIndexOf('/'));
|
||||
storageLocation = configDir;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Use default if we can't determine the path
|
||||
}
|
||||
|
||||
const helpText = `
|
||||
<ol>
|
||||
<li>Start opencode server: <code>opencode serve</code></li>
|
||||
<li>Run opencode in another terminal to create sessions</li>
|
||||
<li>Use <code>/export</code> command to export sessions for permanent local storage</li>
|
||||
<li>View sessions here without sharing them remotely</li>
|
||||
</ol>
|
||||
<p><strong>Storage Location:</strong> Exported sessions are stored in: <code>${storageLocation}</code></p>
|
||||
<p><strong>Note:</strong> These sessions are stored permanently in your local config directory.</p>
|
||||
`;
|
||||
|
||||
---
|
||||
<StarlightPage
|
||||
hasSidebar={false}
|
||||
frontmatter={{
|
||||
title: "Exported Sessions",
|
||||
pagefind: false,
|
||||
template: "splash",
|
||||
tableOfContents: false,
|
||||
head: [
|
||||
{
|
||||
tag: "meta",
|
||||
attrs: {
|
||||
name: "description",
|
||||
content: "opencode - Local Session Browser",
|
||||
},
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<SessionsList
|
||||
sessions={sessions}
|
||||
title="Exported Sessions"
|
||||
emptyMessage="No exported sessions found. Use /export command in opencode to export sessions here."
|
||||
helpText={helpText}
|
||||
error={error}
|
||||
apiUrl={localApiUrl}
|
||||
basePath="/local"
|
||||
client:only="solid"
|
||||
/>
|
||||
</StarlightPage>
|
||||
|
83
packages/web/src/pages/project/[sessionId].astro
Normal file
83
packages/web/src/pages/project/[sessionId].astro
Normal file
|
@ -0,0 +1,83 @@
|
|||
---
|
||||
import StarlightPage from '@astrojs/starlight/components/StarlightPage.astro';
|
||||
import Share from "../../components/Share.tsx";
|
||||
import { fetchProjectSessions, fetchSessionMessages } from "../../lib/local-session-utils";
|
||||
|
||||
const localApiUrl = import.meta.env.VITE_LOCAL_API_URL || "http://localhost:4096";
|
||||
const { sessionId } = Astro.params;
|
||||
|
||||
let sessionInfo;
|
||||
let messages;
|
||||
let error = null;
|
||||
|
||||
try {
|
||||
const [sessions, messagesList] = await Promise.all([
|
||||
fetchProjectSessions(localApiUrl),
|
||||
fetchSessionMessages(localApiUrl, sessionId)
|
||||
]);
|
||||
|
||||
sessionInfo = sessions.find((s: any) => s.id === sessionId);
|
||||
messages = messagesList;
|
||||
|
||||
if (!sessionInfo) {
|
||||
return new Response(null, {
|
||||
status: 404,
|
||||
statusText: 'Project session not found'
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return new Response(null, {
|
||||
status: 500,
|
||||
statusText: error
|
||||
});
|
||||
}
|
||||
|
||||
const messagesObj = {};
|
||||
messages.forEach((msg: any) => {
|
||||
messagesObj[msg.id] = msg;
|
||||
});
|
||||
|
||||
---
|
||||
<StarlightPage
|
||||
hasSidebar={false}
|
||||
frontmatter={{
|
||||
title: sessionInfo.title,
|
||||
pagefind: false,
|
||||
template: "splash",
|
||||
tableOfContents: false,
|
||||
head: [
|
||||
{
|
||||
tag: "meta",
|
||||
attrs: {
|
||||
name: "description",
|
||||
content: "opencode - Project Session Viewer",
|
||||
},
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<Share
|
||||
id={sessionId}
|
||||
api={localApiUrl}
|
||||
info={sessionInfo}
|
||||
messages={messagesObj}
|
||||
client:only="solid"
|
||||
/>
|
||||
</StarlightPage>
|
||||
|
||||
<style is:global>
|
||||
body > .page > .main-frame .main-pane > main > .content-panel:first-of-type {
|
||||
display: none;
|
||||
}
|
||||
body > .page > .main-frame .main-pane > main {
|
||||
padding: 0;
|
||||
}
|
||||
body > .page > .main-frame .main-pane > main > .content-panel + .content-panel {
|
||||
border-top: none !important;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
81
packages/web/src/pages/project/index.astro
Normal file
81
packages/web/src/pages/project/index.astro
Normal file
|
@ -0,0 +1,81 @@
|
|||
---
|
||||
import StarlightPage from "@astrojs/starlight/components/StarlightPage.astro"
|
||||
import SessionsList from "../../components/SessionsList.tsx"
|
||||
import {
|
||||
fetchProjectSessions,
|
||||
computeSessionData,
|
||||
fetchSessionMessages,
|
||||
} from "../../lib/local-session-utils"
|
||||
import "../../styles/sessions.css"
|
||||
|
||||
const localApiUrl =
|
||||
import.meta.env.VITE_LOCAL_API_URL || "http://localhost:4096"
|
||||
|
||||
let sessions = []
|
||||
let error = null
|
||||
|
||||
try {
|
||||
const sessionList = await fetchProjectSessions(localApiUrl)
|
||||
|
||||
const sessionsWithData = await Promise.all(
|
||||
sessionList.map(async (session: any) => {
|
||||
// Fetch messages for each session to compute real token data
|
||||
const messages = await fetchSessionMessages(localApiUrl, session.id)
|
||||
const computedData = computeSessionData(session, messages)
|
||||
|
||||
return {
|
||||
...session,
|
||||
computedData,
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
sessions = sessionsWithData.sort(
|
||||
(a: any, b: any) => b.time.updated - a.time.updated
|
||||
)
|
||||
} catch (e) {
|
||||
error = e.message
|
||||
}
|
||||
|
||||
const helpText = `
|
||||
<ol>
|
||||
<li>Start opencode server: <code>opencode serve</code></li>
|
||||
<li>Run opencode in another terminal to create sessions</li>
|
||||
<li>Active project sessions appear here</li>
|
||||
<li>Use <code>/export</code> command to move sessions to permanent local storage</li>
|
||||
</ol>
|
||||
<p><strong>Note:</strong> These are active project sessions that may be cleaned up over time.</p>
|
||||
`
|
||||
---
|
||||
|
||||
<StarlightPage
|
||||
hasSidebar={false}
|
||||
frontmatter={{
|
||||
title: "Project Sessions",
|
||||
pagefind: false,
|
||||
template: "splash",
|
||||
tableOfContents: false,
|
||||
head: [
|
||||
{
|
||||
tag: "meta",
|
||||
attrs: {
|
||||
name: "description",
|
||||
content: "opencode - Project Session Browser",
|
||||
},
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<SessionsList
|
||||
sessions={sessions}
|
||||
title="Project Sessions"
|
||||
emptyMessage="No active project sessions found. Start opencode to create sessions."
|
||||
helpText={helpText}
|
||||
error={error}
|
||||
apiUrl={localApiUrl}
|
||||
basePath="/project"
|
||||
client:only="solid"
|
||||
/>
|
||||
</StarlightPage>
|
||||
|
||||
|
186
packages/web/src/styles/sessions.css
Normal file
186
packages/web/src/styles/sessions.css
Normal file
|
@ -0,0 +1,186 @@
|
|||
/* Shared styles for session lists */
|
||||
|
||||
body > .page > .main-frame .main-pane > main > .content-panel:first-of-type {
|
||||
display: none;
|
||||
}
|
||||
|
||||
body > .page > .main-frame .main-pane > main {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body > .page > .main-frame .main-pane > main > .content-panel + .content-panel {
|
||||
border-top: none !important;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.local-sessions {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.local-sessions h1 {
|
||||
color: var(--sl-color-white);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: var(--sl-color-red-low);
|
||||
border: 1px solid var(--sl-color-red);
|
||||
color: var(--sl-color-red-high);
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.error-message p {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.9em;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
color: var(--sl-color-gray-3);
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.sessions-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2.5rem;
|
||||
}
|
||||
|
||||
.session-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 1.5rem;
|
||||
background: var(--sl-color-gray-6);
|
||||
border: 1px solid var(--sl-color-gray-5);
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.session-item:hover {
|
||||
background: var(--sl-color-gray-5);
|
||||
border-color: var(--sl-color-accent);
|
||||
}
|
||||
|
||||
.session-title h3 {
|
||||
font-size: 2rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.05em;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@media (max-width: 30rem) {
|
||||
.session-title h3 {
|
||||
font-size: 1.75rem;
|
||||
line-height: 1.25;
|
||||
}
|
||||
}
|
||||
|
||||
.session-link {
|
||||
color: var(--sl-color-white);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.session-link:hover {
|
||||
color: var(--sl-color-accent);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
[data-section="row"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
[data-section="stats"] {
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
gap: 0.5rem 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
[data-section="stats"] li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
[data-section="stats"][data-section-root] li,
|
||||
[data-section="stats"][data-section-models] li {
|
||||
gap: 0.3125rem;
|
||||
}
|
||||
|
||||
[data-stat-icon] {
|
||||
flex: 0 0 auto;
|
||||
color: var(--sl-color-text-dimmed);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
[data-stat-icon] svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
[data-element-label] {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: -0.5px;
|
||||
color: var(--sl-color-text-dimmed);
|
||||
}
|
||||
|
||||
[data-placeholder] {
|
||||
color: var(--sl-color-text-dimmed);
|
||||
}
|
||||
|
||||
[data-stat-model] {
|
||||
color: var(--sl-color-text);
|
||||
}
|
||||
|
||||
[data-section="time"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
[data-section="time"] span {
|
||||
color: var(--sl-color-text-dimmed);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.help-section {
|
||||
margin-top: 2rem;
|
||||
padding: 1rem;
|
||||
background: var(--sl-color-blue-low);
|
||||
border: 1px solid var(--sl-color-blue);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.help-section h3 {
|
||||
color: var(--sl-color-blue-high);
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.help-section ol {
|
||||
color: var(--sl-color-gray-2);
|
||||
}
|
||||
|
||||
.help-section code {
|
||||
background: var(--sl-color-gray-6);
|
||||
color: var(--sl-color-accent);
|
||||
padding: 0.2em 0.4em;
|
||||
border-radius: 4px;
|
||||
font-family: var(--sl-font-mono);
|
||||
}
|
|
@ -88,6 +88,9 @@ resources:
|
|||
filePart: FilePart
|
||||
stepStartPart: StepStartPart
|
||||
messagePart: MessagePart
|
||||
exportResult: ExportResult
|
||||
exportedSession: ExportedSession
|
||||
exportedSessionInfo: ExportedSessionInfo
|
||||
methods:
|
||||
list: get /session
|
||||
create: post /session
|
||||
|
@ -99,6 +102,9 @@ resources:
|
|||
summarize: post /session/{id}/summarize
|
||||
messages: get /session/{id}/message
|
||||
chat: post /session/{id}/message
|
||||
export: post /session/{id}/export
|
||||
listExported: get /session/export
|
||||
getExported: get /session/{id}/export
|
||||
|
||||
settings:
|
||||
disable_mock_tests: true
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue