diff --git a/packages/console/app/src/routes/changelog.json.ts b/packages/console/app/src/routes/changelog.json.ts new file mode 100644 index 00000000000..a23f2050324 --- /dev/null +++ b/packages/console/app/src/routes/changelog.json.ts @@ -0,0 +1,114 @@ +type Release = { + tag_name: string + name: string + body: string + published_at: string + html_url: string +} + +type HighlightMedia = { type: "video"; src: string } | { type: "image"; src: string; width: string; height: string } + +type HighlightItem = { + title: string + description: string + shortDescription?: string + media: HighlightMedia +} + +type HighlightGroup = { + source: string + items: HighlightItem[] +} + +function parseHighlights(body: string): HighlightGroup[] { + const groups = new Map() + const regex = /([\s\S]*?)<\/highlight>/g + let match + + while ((match = regex.exec(body)) !== null) { + const source = match[1] + const content = match[2] + + const titleMatch = content.match(/

([^<]+)<\/h2>/) + const pMatch = content.match(/([^<]+)<\/p>/) + const imgMatch = content.match(/ ({ source, items })) +} + +function parseMarkdown(body: string) { + const lines = body.split("\n") + const sections: { title: string; items: string[] }[] = [] + let current: { title: string; items: string[] } | null = null + let skip = false + + for (const line of lines) { + if (line.startsWith("## ")) { + if (current) sections.push(current) + const title = line.slice(3).trim() + current = { title, items: [] } + skip = false + } else if (line.startsWith("**Thank you")) { + skip = true + } else if (line.startsWith("- ") && !skip) { + current?.items.push(line.slice(2).trim()) + } + } + if (current) sections.push(current) + + const highlights = parseHighlights(body) + + return { sections, highlights } +} + +export async function GET() { + const response = await fetch("https://api.github.com/repos/anomalyco/opencode/releases?per_page=20", { + headers: { + Accept: "application/vnd.github.v3+json", + "User-Agent": "OpenCode-Console", + }, + }) + + if (!response.ok) { + return { releases: [] } + } + + const releases = (await response.json()) as Release[] + + return { + releases: releases.map((release) => { + const parsed = parseMarkdown(release.body || "") + return { + tag: release.tag_name, + name: release.name, + date: release.published_at, + url: release.html_url, + highlights: parsed.highlights, + sections: parsed.sections, + } + }), + } +} diff --git a/packages/console/app/src/routes/changelog/index.css b/packages/console/app/src/routes/changelog/index.css index a445c74474a..233d85cc0e6 100644 --- a/packages/console/app/src/routes/changelog/index.css +++ b/packages/console/app/src/routes/changelog/index.css @@ -367,11 +367,18 @@ display: flex; flex-direction: column; gap: 4px; + position: sticky; + top: 80px; + align-self: start; + background: var(--color-background); + padding: 8px 0; @media (max-width: 50rem) { + position: static; flex-direction: row; align-items: center; gap: 12px; + padding: 0; } [data-slot="version"] { @@ -402,24 +409,26 @@ [data-component="section"] { h3 { - font-size: 14px; + font-size: 13px; font-weight: 600; color: var(--color-text-strong); - margin-bottom: 8px; + margin-bottom: 6px; } ul { list-style: none; padding: 0; margin: 0; + padding-left: 16px; display: flex; flex-direction: column; - gap: 6px; + gap: 4px; li { color: var(--color-text); + font-size: 13px; line-height: 1.5; - padding-left: 16px; + padding-left: 12px; position: relative; &::before { @@ -431,7 +440,7 @@ [data-slot="author"] { color: var(--color-text-weak); - font-size: 13px; + font-size: 12px; margin-left: 4px; text-decoration: none; @@ -465,6 +474,120 @@ } } } + + [data-component="highlights"] { + display: flex; + flex-direction: column; + gap: 2rem; + margin-bottom: 1.5rem; + } + + [data-component="collapsible-sections"] { + display: flex; + flex-direction: column; + gap: 0; + } + + [data-component="collapsible-section"] { + [data-slot="toggle"] { + display: flex; + align-items: center; + gap: 6px; + background: none; + border: none; + padding: 6px 0; + cursor: pointer; + font-family: inherit; + font-size: 13px; + font-weight: 600; + color: var(--color-text-weak); + + &:hover { + color: var(--color-text); + } + + [data-slot="icon"] { + font-size: 10px; + } + } + + ul { + list-style: none; + padding: 0; + margin: 0; + padding-left: 16px; + padding-bottom: 8px; + + li { + color: var(--color-text); + font-size: 13px; + line-height: 1.5; + padding-left: 12px; + position: relative; + + &::before { + content: "-"; + position: absolute; + left: 0; + color: var(--color-text-weak); + } + + [data-slot="author"] { + color: var(--color-text-weak); + font-size: 12px; + margin-left: 4px; + text-decoration: none; + + &:hover { + text-decoration: underline; + text-underline-offset: 2px; + text-decoration-thickness: 1px; + } + } + } + } + } + + [data-component="highlight"] { + h4 { + font-size: 14px; + font-weight: 600; + color: var(--color-text-strong); + margin-bottom: 8px; + } + + hr { + border: none; + border-top: 1px solid var(--color-border-weak); + margin-bottom: 16px; + } + + [data-slot="highlight-item"] { + margin-bottom: 24px; + + &:last-child { + margin-bottom: 0; + } + + p[data-slot="title"] { + font-weight: 600; + font-size: 16px; + margin-bottom: 4px; + } + + p { + font-size: 14px; + margin-bottom: 12px; + } + } + + img, + video { + max-width: 100%; + height: auto; + border-radius: 4px; + } + } } a { diff --git a/packages/console/app/src/routes/changelog/index.tsx b/packages/console/app/src/routes/changelog/index.tsx index c1b931fe3e0..87e021ec88c 100644 --- a/packages/console/app/src/routes/changelog/index.tsx +++ b/packages/console/app/src/routes/changelog/index.tsx @@ -5,7 +5,7 @@ import { Header } from "~/component/header" import { Footer } from "~/component/footer" import { Legal } from "~/component/legal" import { config } from "~/config" -import { For, Show } from "solid-js" +import { For, Show, createSignal } from "solid-js" type Release = { tag_name: string @@ -40,6 +40,59 @@ function formatDate(dateString: string) { }) } +type HighlightMedia = { type: "video"; src: string } | { type: "image"; src: string; width: string; height: string } + +type HighlightItem = { + title: string + description: string + shortDescription?: string + media: HighlightMedia +} + +type HighlightGroup = { + source: string + items: HighlightItem[] +} + +function parseHighlights(body: string): HighlightGroup[] { + const groups = new Map() + const regex = /([\s\S]*?)<\/highlight>/g + let match + + while ((match = regex.exec(body)) !== null) { + const source = match[1] + const content = match[2] + + const titleMatch = content.match(/

([^<]+)<\/h2>/) + const pMatch = content.match(/([^<]+)<\/p>/) + const imgMatch = content.match(/ ({ source, items })) +} + function parseMarkdown(body: string) { const lines = body.split("\n") const sections: { title: string; items: string[] }[] = [] @@ -60,7 +113,9 @@ function parseMarkdown(body: string) { } if (current) sections.push(current) - return { sections } + const highlights = parseHighlights(body) + + return { sections, highlights } } function ReleaseItem(props: { item: string }) { @@ -87,6 +142,60 @@ function ReleaseItem(props: { item: string }) { ) } +function HighlightSection(props: { group: HighlightGroup }) { + return ( +
+

{props.group.source}

+
+ + {(item) => ( +
+

{item.title}

+

{item.description}

+ + + + {item.title} + +
+ )} +
+
+ ) +} + +function CollapsibleSection(props: { section: { title: string; items: string[] } }) { + const [open, setOpen] = createSignal(false) + + return ( +
+ + +
    + {(item) => } +
+
+
+ ) +} + +function CollapsibleSections(props: { sections: { title: string; items: string[] }[] }) { + return ( +
+ {(section) => } +
+ ) +} + export default function Changelog() { const releases = createAsync(() => getReleases()) @@ -120,16 +229,26 @@ export default function Changelog() {
- - {(section) => ( -
-

{section.title}

-
    - {(item) => } -
-
- )} -
+ 0}> +
+ {(group) => } +
+
+ 0 && parsed().sections.length > 0}> + + + + + {(section) => ( +
+

{section.title}

+
    + {(item) => } +
+
+ )} +
+
) diff --git a/packages/desktop/src-tauri/tauri.conf.json b/packages/desktop/src-tauri/tauri.conf.json index f8df151bdf8..d90d53fe8b5 100644 --- a/packages/desktop/src-tauri/tauri.conf.json +++ b/packages/desktop/src-tauri/tauri.conf.json @@ -2,7 +2,7 @@ "$schema": "https://schema.tauri.app/config/2", "productName": "OpenCode Dev", "identifier": "ai.opencode.desktop.dev", - "mainBinaryName": "OpenCode", + "mainBinaryName": "opencode-desktop", "version": "../package.json", "build": { "beforeDevCommand": "bun run dev", diff --git a/packages/desktop/src-tauri/tauri.prod.conf.json b/packages/desktop/src-tauri/tauri.prod.conf.json index 282db5b26c6..7f39017e8a4 100644 --- a/packages/desktop/src-tauri/tauri.prod.conf.json +++ b/packages/desktop/src-tauri/tauri.prod.conf.json @@ -2,6 +2,7 @@ "$schema": "https://schema.tauri.app/config/2", "productName": "OpenCode", "identifier": "ai.opencode.desktop", + "mainBinaryName": "opencode-desktop", "app": { "windows": [ { diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 392cfb7f121..eb8ed2d9bba 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -333,32 +333,57 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const start = Date.now() - 30 * 24 * 60 * 60 * 1000 const sessionListPromise = sdk.client.session .list({ start: start }) - .then((x) => setStore("session", reconcile((x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id))))) + .then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id))) // blocking - include session.list when continuing a session + const providersPromise = sdk.client.config.providers({}, { throwOnError: true }) + const providerListPromise = sdk.client.provider.list({}, { throwOnError: true }) + const agentsPromise = sdk.client.app.agents({}, { throwOnError: true }) + const configPromise = sdk.client.config.get({}, { throwOnError: true }) const blockingRequests: Promise[] = [ - sdk.client.config.providers({}, { throwOnError: true }).then((x) => { - batch(() => { - setStore("provider", reconcile(x.data!.providers)) - setStore("provider_default", reconcile(x.data!.default)) - }) - }), - sdk.client.provider.list({}, { throwOnError: true }).then((x) => { - batch(() => { - setStore("provider_next", reconcile(x.data!)) - }) - }), - sdk.client.app.agents({}, { throwOnError: true }).then((x) => setStore("agent", reconcile(x.data ?? []))), - sdk.client.config.get({}, { throwOnError: true }).then((x) => setStore("config", reconcile(x.data!))), + providersPromise, + providerListPromise, + agentsPromise, + configPromise, ...(args.continue ? [sessionListPromise] : []), ] await Promise.all(blockingRequests) + .then(() => { + const providersResponse = providersPromise.then((x) => x.data!) + const providerListResponse = providerListPromise.then((x) => x.data!) + const agentsResponse = agentsPromise.then((x) => x.data ?? []) + const configResponse = configPromise.then((x) => x.data!) + const sessionListResponse = args.continue ? sessionListPromise : undefined + + return Promise.all([ + providersResponse, + providerListResponse, + agentsResponse, + configResponse, + ...(sessionListResponse ? [sessionListResponse] : []), + ]).then((responses) => { + const providers = responses[0] + const providerList = responses[1] + const agents = responses[2] + const config = responses[3] + const sessions = responses[4] + + batch(() => { + setStore("provider", reconcile(providers.providers)) + setStore("provider_default", reconcile(providers.default)) + setStore("provider_next", reconcile(providerList)) + setStore("agent", reconcile(agents)) + setStore("config", reconcile(config)) + if (sessions !== undefined) setStore("session", reconcile(sessions)) + }) + }) + }) .then(() => { if (store.status !== "complete") setStore("status", "partial") // non-blocking Promise.all([ - ...(args.continue ? [] : [sessionListPromise]), + ...(args.continue ? [] : [sessionListPromise.then((sessions) => setStore("session", reconcile(sessions)))]), sdk.client.command.list().then((x) => setStore("command", reconcile(x.data ?? []))), sdk.client.lsp.status().then((x) => setStore("lsp", reconcile(x.data!))), sdk.client.mcp.status().then((x) => setStore("mcp", reconcile(x.data!))),