This commit is contained in:
Netanel Draiman 2025-07-06 23:45:39 -04:00 committed by GitHub
commit ac25e5de5c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 1163 additions and 1 deletions

View file

@ -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({

View file

@ -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)

View file

@ -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",

View file

@ -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)

View 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>&mdash;</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>&mdash;</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>&mdash;</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>&mdash;</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>&mdash;</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 &mdash;
</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>
)
}

View file

@ -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,

View 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()
}

View 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>

View 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>

View 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>

View 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>

View 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);
}

View file

@ -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