Compare commits

..

5 Commits

Author SHA1 Message Date
Brendan Allan 857f20a183 fix(app): increase initial message page size 2026-07-08 12:18:07 +08:00
Brendan Allan 6cfe4bc34f fix(app): route new session shortcut through tabs (#35849) 2026-07-08 11:44:56 +08:00
Brendan Allan 95013d2936 fix(app): keep session routes within layouts (#35842) 2026-07-08 03:24:21 +00:00
Adam 14a5529793 feat(data): redesign model peers 2026-07-07 11:15:43 -05:00
opencode 0b2e8707e3 sync release versions for v1.17.15 2026-07-07 15:31:36 +00:00
11 changed files with 154 additions and 65 deletions
+58 -13
View File
@@ -9,7 +9,7 @@ import { Font } from "@opencode-ai/ui/font"
import { Splash } from "@opencode-ai/ui/logo"
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
import { MetaProvider } from "@solidjs/meta"
import { type BaseRouterProps, Navigate, Route, Router, useParams, useSearchParams } from "@solidjs/router"
import { type BaseRouterProps, Navigate, Route, Router, useNavigate, useParams, useSearchParams } from "@solidjs/router"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { Effect } from "effect"
import {
@@ -32,7 +32,7 @@ import { CommandProvider, useCommand, type CommandOption } from "@/context/comma
import { CommentsProvider } from "@/context/comments"
import { FileProvider } from "@/context/file"
import { ServerSDKProvider } from "@/context/server-sdk"
import { ServerSyncProvider } from "@/context/server-sync"
import { ServerSyncProvider, useServerSync } from "@/context/server-sync"
import { GlobalProvider, useGlobal } from "@/context/global"
import { HighlightsProvider } from "@/context/highlights"
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
@@ -52,9 +52,10 @@ import LegacyLayout from "@/pages/layout"
import NewLayout from "@/pages/layout-new"
import { ErrorPage } from "./pages/error"
import { useCheckServerHealth } from "./utils/server-health"
import { legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
import { legacySessionHref, legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
import { createSessionLineage } from "@/pages/session/session-lineage"
import { SessionPage, TargetSessionRouteContent } from "@/pages/session"
import { SessionPage, SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session"
import { NewHome, LegacyHome } from "@/pages/home"
const NewSession = lazy(() => import("@/pages/new-session"))
@@ -88,10 +89,14 @@ const SessionRoute = () => {
tabs.newDraft({ server: server.key, directory: sdk().directory }, search.prompt)
})
return <SessionPage />
return (
<SessionRouteErrorBoundary sessionID={params.id}>
<SessionPage />
</SessionRouteErrorBoundary>
)
}
const TargetSessionRoute = () => {
function TargetServerRoute(props: ParentProps) {
const params = useParams<{ serverKey: string; id: string }>()
const global = useGlobal()
const conn = createMemo(() => {
@@ -105,14 +110,47 @@ const TargetSessionRoute = () => {
// re-resolves reactively instead); both rely on this key for server changes.
<Show when={requireServerKey(params.serverKey)} keyed>
<ServerSDKProvider server={conn}>
<ServerSyncProvider server={conn}>
<TargetSessionRouteContent />
</ServerSyncProvider>
<ServerSyncProvider server={conn}>{props.children}</ServerSyncProvider>
</ServerSDKProvider>
</Show>
)
}
const TargetSessionRoute = () => (
<TargetServerRoute>
<TargetSessionRouteContent />
</TargetServerRoute>
)
function LegacyTargetSessionRoute() {
const params = useParams<{ serverKey: string; id: string }>()
return (
<TargetServerRoute>
<SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)}>
<LegacyTargetSessionRedirect />
</SessionRouteErrorBoundary>
</TargetServerRoute>
)
}
function LegacyTargetSessionRedirect() {
const params = useParams<{ id: string }>()
const navigate = useNavigate()
const sync = useServerSync()
const current = createSessionLineage(
() => params.id,
() => sync().session.lineage,
)
createEffect(() => {
const directory = current()?.session.directory
if (!directory) return
navigate(legacySessionHref(directory, params.id), { replace: true })
})
return null
}
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
// server via ServerKey, then provide the server-scoped shell (Permission/Layout/
// Notification/Models + the visual Layout) for that server.
@@ -542,7 +580,14 @@ function Routes(props: { serverScoped?: JSX.Element }) {
<LegacyServerLayout serverScoped={props.serverScoped}>{routeProps.children}</LegacyServerLayout>
)}
>
<Show when={!settings.general.newLayoutDesigns()}>{<Route path="/" component={LegacyHome} />}</Show>
<Show when={!settings.general.newLayoutDesigns()}>
{
<>
<Route path="/" component={LegacyHome} />
<Route path="/server/:serverKey/session/:id" component={LegacyTargetSessionRoute} />
</>
}
</Show>
<Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={() => <Navigate href="session" />} />
<Route path="/session/:id?" component={SessionRoute} />
@@ -550,15 +595,15 @@ function Routes(props: { serverScoped?: JSX.Element }) {
</Route>
<Show when={settings.general.newLayoutDesigns()}>
<Route path="/" component={NewHome} />
<Route path="/:dir/session/:id" component={LegacyTargetSessionRoute} />
<Route path="/:dir/session/:id" component={NewLayoutLegacySessionRedirect} />
<Route path="/server/:serverKey/session/:id" component={TargetSessionRoute} />
</Show>
<Route path="/new-session" component={DraftRoute} />
<Route path="/server/:serverKey/session/:id" component={TargetSessionRoute} />
</>
)
}
function LegacyTargetSessionRoute() {
function NewLayoutLegacySessionRedirect() {
const server = useServer()
const tabs = useTabs()
const params = useParams<{ id: string }>()
@@ -174,7 +174,7 @@ describe("server session", () => {
await ctx.store.sync("root")
expect(ctx.get).toEqual([{ sessionID: "root" }])
expect(ctx.messages).toEqual([{ sessionID: "root", limit: 2, before: undefined }])
expect(ctx.messages).toEqual([{ sessionID: "root", limit: 20, before: undefined }])
expect(ctx.store.data.message.root).toEqual([])
})
@@ -194,7 +194,7 @@ describe("server session", () => {
await store.sync("child")
expect(client.requests).toEqual([{ sessionID: "child", limit: 2, before: undefined }])
expect(client.requests).toEqual([{ sessionID: "child", limit: 20, before: undefined }])
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: user.id }])
expect(store.data.message.child).toEqual([user, ...assistants])
expect(store.history.more("child")).toBe(true)
+1 -1
View File
@@ -21,7 +21,7 @@ import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } fro
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)
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
const initialMessagePageSize = 2
const initialMessagePageSize = 20
const historyMessagePageSize = 200
const sessionInfoLimit = 2_048
const emptyIDs: ReadonlySet<string> = new Set()
+2 -2
View File
@@ -35,7 +35,7 @@ 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 { useSettingsDialog } 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"
@@ -267,7 +267,7 @@ export function NewHome() {
const command = useCommand()
const notification = useNotification()
const marked = useMarked()
const openSettings = useSettingsCommand()
const openSettings = useSettingsDialog()
let focusSessionSearch: (() => void) | undefined
const [state, setState] = createStore({
search: "",
+2
View File
@@ -6,11 +6,13 @@ import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
import { usePlatform } from "@/context/platform"
import { setNavigate } from "@/utils/notification-click"
import { setV2Toast, ToastRegion } from "@/utils/toast"
import { useSettingsCommand } from "@/components/settings-dialog"
export default function NewLayout(props: ParentProps) {
const platform = usePlatform()
const navigate = useNavigate()
setNavigate(navigate)
useSettingsCommand()
createEffect(() => setV2Toast(true))
-2
View File
@@ -6,7 +6,6 @@ import { Tooltip } from "@opencode-ai/ui/tooltip"
import { NewSessionDesignView } from "@/components/session"
import { PromptInput } from "@/components/prompt-input"
import { StatusPopoverV2 } from "@/components/status-popover"
import { useSettingsCommand } from "@/components/settings-dialog"
import {
PromptProjectAddButton,
PromptProjectSelector,
@@ -45,7 +44,6 @@ export default function NewSessionPage() {
const [searchParams, setSearchParams] = useSearchParams<{ draftId?: string; prompt?: string }>()
useComposerCommands()
useSettingsCommand()
let inputRef: HTMLDivElement | undefined
+7 -15
View File
@@ -35,7 +35,7 @@ import { previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-b
import { Button } from "@opencode-ai/ui/button"
import { showToast } from "@/utils/toast"
import { base64Encode, checksum } from "@opencode-ai/core/util/encode"
import { Navigate, useLocation, useNavigate, useParams, useSearchParams } from "@solidjs/router"
import { useLocation, useNavigate, useParams, useSearchParams } from "@solidjs/router"
import { NewSessionView, SessionHeader } from "@/components/session"
import { ErrorPage } from "@/pages/error"
import { CommentsProvider, useComments } from "@/context/comments"
@@ -56,7 +56,6 @@ import { useSync } from "@/context/sync"
import { useTabs } from "@/context/tabs"
import { TerminalProvider, useTerminal } from "@/context/terminal"
import { PromptInput } from "@/components/prompt-input"
import { useSettingsCommand } from "@/components/settings-dialog"
import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit"
import {
createPromptInputController,
@@ -153,7 +152,7 @@ export function TargetSessionRouteContent() {
)
}
function SessionRouteErrorBoundary(
export function SessionRouteErrorBoundary(
props: ParentProps<{ sessionID?: string; serverKey?: ServerConnection.Key; padded?: boolean }>,
) {
const settings = useSettings()
@@ -221,7 +220,6 @@ function SessionErrorFallback(props: { error: unknown; sessionID?: string; serve
function ResolvedTargetSessionRoute() {
const params = useParams<{ serverKey: string; id: string }>()
const settings = useSettings()
const tabs = useTabs()
const sync = useServerSync()
const serverKey = createMemo(() => requireServerKey(params.serverKey))
@@ -248,16 +246,11 @@ function ResolvedTargetSessionRoute() {
the terminal. Same-workspace tab switches keep it open because warm
targets resolve synchronously from the sync cache. */}
<Show when={directory()}>
<Show
when={settings.general.newLayoutDesigns()}
fallback={<Navigate href={legacySessionHref(directory()!, params.id)} />}
>
<SDKProvider directory={targetDirectory}>
<DirectoryDataProvider directory={targetDirectory} server={serverKey}>
<TargetSessionPage />
</DirectoryDataProvider>
</SDKProvider>
</Show>
<SDKProvider directory={targetDirectory}>
<DirectoryDataProvider directory={targetDirectory} server={serverKey}>
<TargetSessionPage />
</DirectoryDataProvider>
</SDKProvider>
</Show>
</TargetServerScopedProviders>
)
@@ -1021,7 +1014,6 @@ export default function Page() {
}
useComposerCommands()
useSettingsCommand()
useSessionCommands({
navigateMessageByOffset,
setActiveMessage,
@@ -18,8 +18,6 @@ import { createSessionTabs } from "@/pages/session/helpers"
import { extractPromptFromParts } from "@/utils/prompt"
import { UserMessage } from "@opencode-ai/sdk/v2"
import { useSessionLayout } from "@/pages/session/session-layout"
import { useTabs } from "@/context/tabs"
import { requireServerKey } from "@/utils/session-route"
import { createSessionOwnership } from "./session-ownership"
export type SessionCommandContext = {
@@ -48,7 +46,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const settings = useSettings()
const sync = useSync()
const terminal = useTerminal()
const sessionTabs = useTabs()
const layout = useLayout()
const navigate = useNavigate()
const { params, sessionKey, tabs, view } = useSessionLayout()
@@ -419,9 +416,9 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
title: language.t("command.session.new"),
keybind: "mod+shift+s",
slash: "new",
onSelect: () => {
if (params.serverKey) {
sessionTabs.newDraft({ server: requireServerKey(params.serverKey), directory: sdk().directory })
onSelect: (source) => {
if (settings.general.newLayoutDesigns()) {
command.trigger("tab.new", source)
return
}
navigate(`/${params.dir}/session`)
@@ -1143,10 +1143,16 @@ function PeerRow(props: { peer: ModelPeerEntry; active: boolean }) {
href={language.route(`${import.meta.env.BASE_URL}${providerSlug(props.peer.provider)}/${props.peer.slug}`)}
data-active={props.active ? "true" : undefined}
>
<span>{String(props.peer.rank).padStart(2, "0")}</span>
<ProviderIcon aria-hidden="true" id={getProviderIconId(props.peer.author)} />
<strong>{props.peer.model}</strong>
<em>{props.peer.author}</em>
<span data-slot="model-peer-rank" aria-label={props.active ? `Rank ${props.peer.rank}` : undefined}>
<Show when={!props.active}>{String(props.peer.rank).padStart(2, "0")}</Show>
</span>
<span data-slot="model-peer-avatar">
<ProviderIcon aria-hidden="true" id={getProviderIconId(props.peer.author)} />
</span>
<span data-slot="model-peer-copy">
<strong>{props.peer.model}</strong>
<em>{props.peer.author}</em>
</span>
<b>{formatTokens(props.peer.tokens)}</b>
</a>
</li>
+67 -18
View File
@@ -5159,7 +5159,10 @@
[data-page="stats"] [data-component="model-peer-list"] {
display: grid;
gap: 8px;
grid-auto-flow: column;
grid-auto-columns: minmax(0, 1fr);
grid-template-rows: repeat(5, 64px);
gap: 12px 40px;
margin: 0;
padding: 0;
list-style: none;
@@ -5167,11 +5170,12 @@
[data-page="stats"] [data-component="model-peer-list"] a {
display: grid;
grid-template-columns: 38px 24px minmax(0, 1fr) minmax(96px, auto) minmax(80px, auto);
gap: 12px;
grid-template-columns: 32px 32px minmax(0, 1fr) minmax(64px, auto);
gap: 16px;
align-items: center;
min-height: 44px;
padding: 0 12px;
box-sizing: border-box;
height: 64px;
padding: 15px 16px;
border: 1px solid var(--stats-line);
background: var(--stats-layer);
color: var(--stats-text);
@@ -5180,25 +5184,56 @@
[data-page="stats"] [data-component="model-peer-list"] a:hover,
[data-page="stats"] [data-component="model-peer-list"] a[data-active="true"] {
border-color: var(--stats-line-strong);
border-color: var(--stats-text);
background: var(--stats-bg);
text-decoration: none;
}
[data-page="stats"] [data-component="model-peer-list"] span,
[data-page="stats"] [data-slot="model-peer-rank"],
[data-page="stats"] [data-component="model-peer-list"] em,
[data-page="stats"] [data-component="model-peer-list"] b {
color: var(--stats-muted);
font-size: 11px;
font-size: 13px;
font-style: normal;
font-weight: 500;
line-height: 1.2;
line-height: 16px;
font-variant-numeric: tabular-nums;
}
[data-page="stats"] [data-component="model-peer-list"] svg {
[data-page="stats"] [data-slot="model-peer-rank"] {
position: relative;
display: grid;
place-items: center;
width: 32px;
height: 32px;
}
[data-page="stats"] [data-component="model-peer-list"] a[data-active="true"] [data-slot="model-peer-rank"]::before {
width: 6px;
height: 6px;
content: "";
background: var(--stats-text);
}
[data-page="stats"] [data-slot="model-peer-avatar"] {
display: grid;
place-items: center;
width: 32px;
height: 32px;
color: var(--stats-muted);
background: var(--stats-bg);
box-shadow: inset 0 0 0 1px var(--stats-line);
}
[data-page="stats"] [data-slot="model-peer-avatar"] svg {
width: 18px;
height: 18px;
color: var(--stats-muted);
}
[data-page="stats"] [data-slot="model-peer-copy"] {
display: grid;
gap: 2px;
min-width: 0;
}
[data-page="stats"] [data-component="model-peer-list"] strong {
@@ -5207,11 +5242,16 @@
color: var(--stats-text);
font-size: 13px;
font-weight: 600;
line-height: 1.2;
line-height: 16px;
text-overflow: ellipsis;
white-space: nowrap;
}
[data-page="stats"] [data-component="model-peer-list"] b {
justify-self: end;
color: var(--stats-text);
}
[data-page="stats"][data-theme="dark"],
:root[data-stats-theme="dark"] [data-page="stats"]:not([data-theme="light"]) {
color-scheme: dark;
@@ -5749,11 +5789,12 @@
}
[data-page="stats"] [data-component="model-peer-list"] a {
grid-template-columns: 32px 22px minmax(0, 1fr) minmax(72px, auto);
grid-template-columns: 32px 32px minmax(0, 1fr) minmax(64px, auto);
gap: 12px;
}
[data-page="stats"] [data-component="model-peer-list"] em {
display: none;
display: block;
}
[data-page="stats"] [data-slot="chart-footer"] {
@@ -6304,12 +6345,20 @@
min-width: 0;
}
[data-page="stats"] [data-component="model-peer-list"] a {
grid-template-columns: 30px minmax(0, 1fr) minmax(64px, auto);
[data-page="stats"] [data-component="model-peer-list"] {
grid-auto-flow: row;
grid-auto-columns: auto;
grid-template-rows: none;
gap: 12px;
}
[data-page="stats"] [data-component="model-peer-list"] svg {
display: none;
[data-page="stats"] [data-component="model-peer-list"] a {
grid-template-columns: 28px 32px minmax(0, 1fr) minmax(56px, auto);
gap: 12px;
}
[data-page="stats"] [data-slot="model-peer-rank"] {
width: 28px;
}
[data-page="stats"] [data-section="top-models"] [data-slot="chart-footer"] {
+2 -2
View File
@@ -646,8 +646,8 @@ function buildModelTokenMix(aggregate: ModelAggregate): ModelMixEntry[] {
}
function buildModelPeers(peers: ModelAggregate[], rank: number, totalTokens: number): ModelPeerEntry[] {
const start = Math.max(0, Math.min(rank - 4, Math.max(peers.length - 7, 0)))
return peers.slice(start, start + 7).map((item, index) => ({
const start = Math.max(0, Math.min(rank - 5, Math.max(peers.length - 10, 0)))
return peers.slice(start, start + 10).map((item, index) => ({
model: item.model,
provider: item.provider,
author: formatProvider(item.provider),