feat(app): ui improvements (#32438)

Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
This commit is contained in:
Aarav Sareen
2026-06-25 15:26:37 +10:00
committed by GitHub
co-authored by LukeParkerDev
parent 4c0af7cf2d
commit 1bcb9d7cb9
29 changed files with 415 additions and 131 deletions
@@ -0,0 +1,51 @@
import { expect, test } from "bun:test"
import { SESSION_TABS_REMOVED_EVENT, readSessionTabsRemovedDetail } from "@/components/titlebar-session-events"
import { archiveHomeSession } from "./home-session-archive"
import type { ServerConnection } from "@/context/server"
const remote = "remote" as ServerConnection.Key
test("archiving a Home session removes its open titlebar tab", async () => {
let detail: ReturnType<typeof readSessionTabsRemovedDetail>
let removed = false
window.addEventListener(
SESSION_TABS_REMOVED_EVENT,
(event) => {
detail = readSessionTabsRemovedDetail(event)
},
{ once: true },
)
await archiveHomeSession({
server: remote,
session: { id: "ses_1", directory: "/workspace" },
update: async () => undefined,
remove: () => {
removed = true
},
})
expect(removed).toBe(true)
expect(detail).toEqual({ server: remote, directory: "/workspace", sessionIDs: ["ses_1"] })
})
test("reports archive failures without removing the session", async () => {
const failure = new Error("offline")
let error: unknown
let removed = false
await archiveHomeSession({
server: remote,
session: { id: "ses_1", directory: "/workspace" },
update: async () => Promise.reject(failure),
remove: () => {
removed = true
},
onError: (value) => {
error = value
},
})
expect(error).toBe(failure)
expect(removed).toBe(false)
})
@@ -0,0 +1,37 @@
import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
import type { ServerConnection } from "@/context/server"
type HomeSession = {
id: string
directory: string
}
type SessionUpdate = {
directory: string
sessionID: string
time: { archived: number }
}
export async function archiveHomeSession(input: {
server: ServerConnection.Key
session: HomeSession
update: (value: SessionUpdate) => Promise<unknown>
remove: () => void
onError?: (error: unknown) => void
}) {
await input
.update({
directory: input.session.directory,
sessionID: input.session.id,
time: { archived: Date.now() },
})
.then(() => {
input.remove()
notifySessionTabsRemoved({
server: input.server,
directory: input.session.directory,
sessionIDs: [input.session.id],
})
})
.catch((error) => input.onError?.(error))
}
+164 -84
View File
@@ -14,7 +14,7 @@ import {
Switch,
} from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
import { createStore } from "solid-js/store"
import { createStore, produce } from "solid-js/store"
import { useQuery } from "@tanstack/solid-query"
import { Button } from "@opencode-ai/ui/button"
import { Logo } from "@opencode-ai/ui/logo"
@@ -25,6 +25,7 @@ import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { getProjectAvatarVariant, useLayout, type LocalProject } from "@/context/layout"
import { useNavigate } from "@solidjs/router"
import { base64Encode } from "@opencode-ai/core/util/encode"
@@ -35,7 +36,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useDirectoryPicker } from "@/components/directory-picker"
import { DialogSelectServer, useServerManagementController } from "@/components/dialog-select-server"
import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2"
import { ServerConnection, useServer } from "@/context/server"
import { ServerConnection, serverName, useServer } from "@/context/server"
import { sessionHasOpenTab, useTabs } from "@/context/tabs"
import { useServerSync, type ServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
@@ -43,6 +44,7 @@ import { useNotification } from "@/context/notification"
import {
closeHomeProject,
displayName,
errorMessage,
getProjectAvatarSource,
homeProjectDirectories,
type HomeProjectSelection,
@@ -55,12 +57,15 @@ import { sessionTitle } from "@/utils/session-title"
import { pathKey } from "@/utils/path-key"
import { useGlobal } from "@/context/global"
import { useCommand } from "@/context/command"
import { Binary } from "@opencode-ai/core/util/binary"
import { ServerRowMenu } from "@/components/server/server-row-menu"
import { ServerHealthIndicator } from "@/components/server/server-row"
import { type ServerHealth } from "@/utils/server-health"
import { Persist, persisted } from "@/utils/persist"
import { useMarked } from "@opencode-ai/ui/context/marked"
import { preloadMarkdown } from "@opencode-ai/session-ui/markdown-cache"
import { archiveHomeSession } from "./home-session-archive"
import { showToast } from "@/utils/toast"
const HOME_SESSION_LIMIT = 64
const HOME_ROW_LAYOUT =
@@ -85,7 +90,7 @@ type HomeSessionGroup = {
const HOME_SESSION_SEARCH_RESULTS_ID = "home-session-search-results"
const HOME_SEARCH_RESULT_ROW =
"flex h-10 w-full shrink-0 cursor-default items-center gap-2 border-0 py-3 pl-4 pr-6 text-left transition-[background-color] duration-[120ms] ease-in-out hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
"flex h-10 w-full shrink-0 cursor-default items-center gap-2 border-0 py-3 pl-[18px] pr-6 text-left transition-[background-color] duration-[120ms] ease-in-out hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
const HOME_SEARCH_RESULT_TITLE =
"min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-[13px] leading-4 tracking-[-0.04px] text-v2-text-text-base [font-weight:530]"
const HOME_SEARCH_RESULT_META =
@@ -172,6 +177,19 @@ export function NewHome() {
return directories(project)
})
const search = createMemo(() => state.search.trim())
const searchPlaceholder = createMemo(() => {
const project = selectedProject()
if (project) {
return language.t("home.sessions.search.placeholder.scoped", { scope: displayName(project) })
}
if (global.servers.list().length > 1) {
const conn = focusedServer()
if (conn) {
return language.t("home.sessions.search.placeholder.scoped", { scope: serverName(conn) })
}
}
return language.t("home.sessions.search.placeholder")
})
const sessionLoad = useQuery(() => ({
queryKey: ["home", "sessions", state.selection.server, ...projectDirectories()] as const,
queryFn: async () => {
@@ -258,7 +276,7 @@ export function NewHome() {
command.register("home", () => [
{
id: "home.sessions.search.focus",
title: language.t("home.sessions.search.placeholder"),
title: searchPlaceholder(),
keybind: "mod+f",
hidden: true,
onSelect: () => focusSessionSearch?.(),
@@ -350,6 +368,30 @@ export function NewHome() {
})
}
async function archiveSession(session: Session) {
const conn = focusedServer()
const ctx = focusedServerCtx()
if (!conn || !ctx) return
const [, setStore] = ctx.sync.child(session.directory)
await archiveHomeSession({
server: ServerConnection.key(conn),
session,
update: (value) => ctx.sdk.client.session.update(value),
remove: () =>
setStore(
produce((draft) => {
const match = Binary.search(draft.session, session.id, (s) => s.id)
if (match.found) draft.session.splice(match.index, 1)
}),
),
onError: (error) =>
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(error, language.t("common.requestFailed")),
}),
})
}
function chooseProject(conn: ServerConnection.Any) {
function resolve(result: string | string[] | null) {
addProjects(conn, homeProjectDirectories(result))
@@ -402,10 +444,11 @@ export function NewHome() {
>
<HomeSessionSearch
value={state.search}
placeholder={language.t("home.sessions.search.placeholder")}
placeholder={searchPlaceholder()}
open={searchOpen()}
loading={sessionLoad.isLoading}
results={searchResults()}
showProjectName={!selectedProject()}
server={state.selection.server}
activeServer={state.selection.server === server.key}
noResultsLabel={language.t("home.sessions.search.noResults", { query: search() })}
@@ -446,9 +489,11 @@ export function NewHome() {
{(record) => (
<HomeSessionRow
record={record}
showProjectName={!selectedProject()}
server={state.selection.server}
activeServer={state.selection.server === server.key}
onClick={() => openSession(record.session)}
openSession={openSession}
archiveSession={archiveSession}
/>
)}
</For>
@@ -502,15 +547,17 @@ function HomeProjectColumn(props: {
<div class="flex h-7 min-w-0 items-center justify-between pl-1.5">
<div class={HOME_SECTION_LABEL}>{props.language.t("home.projects")}</div>
<Show when={global.servers.list().length === 1}>
<IconButtonV2
data-action="home-add-project"
variant="ghost-muted"
size="large"
class="titlebar-icon [&_[data-slot=icon-svg]]:text-v2-icon-icon-muted"
icon={<IconV2 name="folder-add-left" />}
onClick={() => props.chooseProject(global.servers.list()[0]!)}
aria-label={props.language.t("home.project.add")}
/>
<TooltipV2 placement="bottom" value={props.language.t("home.project.add")}>
<IconButtonV2
data-action="home-add-project"
variant="ghost-muted"
size="large"
class="titlebar-icon [&_[data-slot=icon-svg]]:text-v2-icon-icon-muted"
icon={<IconV2 name="folder-add-left" />}
onClick={() => props.chooseProject(global.servers.list()[0]!)}
aria-label={props.language.t("home.project.add")}
/>
</TooltipV2>
</Show>
</div>
<Show
@@ -646,7 +693,7 @@ function HomeServerRow(props: {
</span>
</button>
<div
class="absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity group-hover/server:opacity-100 focus-within:opacity-100 data-[menu=true]:opacity-100"
class="absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-1 opacity-0 transition-opacity group-hover/server:opacity-100 focus-within:opacity-100 data-[menu=true]:opacity-100"
data-menu={state.menuOpen}
>
<ServerRowMenu
@@ -656,14 +703,20 @@ function HomeServerRow(props: {
open={state.menuOpen}
onOpenChange={(open) => setState("menuOpen", open)}
/>
<IconButtonV2
data-action="home-add-project"
variant="ghost-muted"
size="small"
icon={<IconV2 name="folder-add-left" />}
aria-label={props.language.t("home.project.add")}
onClick={() => props.chooseProject(props.server)}
/>
<TooltipV2
class="flex shrink-0 items-center"
placement="bottom"
value={props.language.t("home.project.add")}
>
<IconButtonV2
data-action="home-add-project"
variant="ghost-muted"
size="small"
icon={<IconV2 name="folder-add-left" />}
aria-label={props.language.t("home.project.add")}
onClick={() => props.chooseProject(props.server)}
/>
</TooltipV2>
</div>
</div>
)
@@ -733,19 +786,11 @@ function HomeProjectRow(props: {
<span class={HOME_PROJECT_NAV_LABEL}>{displayName(props.project)}</span>
</button>
<div
class="absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity group-hover/project:opacity-100 focus-within:opacity-100 data-[menu=true]:opacity-100"
class="absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-1 opacity-0 transition-opacity group-hover/project:opacity-100 focus-within:opacity-100 data-[menu=true]:opacity-100"
data-menu={state.menuOpen}
>
<IconButtonV2
data-action="home-project-new-session"
variant="ghost-muted"
size="small"
icon={<IconV2 name="edit" />}
aria-label={props.language.t("command.session.new")}
onClick={() => props.openNewSession(props.server, props.project.worktree)}
/>
<MenuV2
gutter={4}
gutter={6}
modal={false}
placement="bottom-end"
open={state.menuOpen}
@@ -765,7 +810,7 @@ function HomeProjectRow(props: {
{props.language.t("command.session.new")}
</MenuV2.Item>
<MenuV2.Item onSelect={() => props.editProject(props.server, props.project)}>
{props.language.t("common.edit")}
{props.language.t("dialog.project.edit.title")}
</MenuV2.Item>
<MenuV2.Item
disabled={props.unseenCount === 0}
@@ -780,6 +825,14 @@ function HomeProjectRow(props: {
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
<IconButtonV2
data-action="home-project-new-session"
variant="ghost-muted"
size="small"
icon={<IconV2 name="edit" />}
aria-label={props.language.t("command.session.new")}
onClick={() => props.openNewSession(props.server, props.project.worktree)}
/>
</div>
</div>
)
@@ -810,7 +863,7 @@ function HomeSessionLeading(props: {
<span
aria-hidden="true"
class="pointer-events-none absolute top-1/2 h-[7px] w-[3px] -translate-y-1/2 rounded-[2px] bg-v2-background-bg-layer-04"
style={{ right: "calc(100% + 12px)" }}
style={{ right: "calc(100% + 5px)" }}
/>
</Show>
<SessionTabAvatar
@@ -829,6 +882,7 @@ function HomeSessionSearch(props: {
open: boolean
loading: boolean
results: HomeSessionRecord[]
showProjectName: boolean
server: ServerConnection.Key
activeServer: boolean
noResultsLabel: string
@@ -906,12 +960,12 @@ function HomeSessionSearch(props: {
)
return (
<div class="ml-4 mr-2 w-[calc(100%_-_24px)]">
<div class="mr-2 w-[calc(100%_-_8px)]">
<div ref={root} data-component="home-session-search" class="relative z-10 w-full">
<Show when={props.open}>
<div
data-component="home-session-search-panel"
class="absolute flex flex-col rounded-[12px] bg-v2-background-bg-base shadow-[var(--v2-elevation-floating)]"
class="absolute flex flex-col overflow-hidden rounded-[12px] bg-v2-background-bg-base shadow-[var(--v2-elevation-floating)]"
style={{
top: "-6px",
left: "-6px",
@@ -919,7 +973,7 @@ function HomeSessionSearch(props: {
}}
>
<div class="flex flex-col pt-9">
<div id={HOME_SESSION_SEARCH_RESULTS_ID} role="listbox" class="flex flex-col gap-4 pt-4 pb-2">
<div id={HOME_SESSION_SEARCH_RESULTS_ID} role="listbox" class="flex flex-col gap-4 pt-4">
<Show
when={!props.loading}
fallback={
@@ -931,29 +985,32 @@ function HomeSessionSearch(props: {
<Show
when={props.results.length > 0}
fallback={
<p class="my-1.5 px-4 text-[13px] leading-4 tracking-[-0.04px] text-v2-text-text-muted [font-weight:440]">
<p class="my-1.5 px-4 pb-2 text-[13px] leading-4 tracking-[-0.04px] text-v2-text-text-muted [font-weight:440]">
{props.noResultsLabel}
</p>
}
>
<div class="flex flex-col">
<p class="my-1.5 px-4 text-[13px] leading-4 tracking-[-0.04px] text-v2-text-text-muted [font-weight:440]">
<p class="my-1.5 pl-[18px] pr-6 text-[13px] leading-4 tracking-[-0.04px] text-v2-text-text-muted [font-weight:440]">
{language.t("home.sessions.search.sessions")}
</p>
<div ref={listRef} class="flex max-h-80 flex-col gap-px overflow-y-auto">
<For each={props.results}>
{(record) => (
<HomeSessionSearchResultRow
record={record}
server={props.server}
activeServer={props.activeServer}
selected={store.active === homeSessionSearchKey(record)}
onHighlight={() => setStore("active", homeSessionSearchKey(record))}
onSelect={(session) => props.onSelect(session)}
/>
)}
</For>
</div>
<ScrollView class="max-h-80" viewportRef={(el) => (listRef = el)}>
<div class="flex flex-col gap-px pb-2">
<For each={props.results}>
{(record) => (
<HomeSessionSearchResultRow
record={record}
showProjectName={props.showProjectName}
server={props.server}
activeServer={props.activeServer}
selected={store.active === homeSessionSearchKey(record)}
onHighlight={() => setStore("active", homeSessionSearchKey(record))}
onSelect={(session) => props.onSelect(session)}
/>
)}
</For>
</div>
</ScrollView>
</div>
</Show>
</Show>
@@ -962,11 +1019,10 @@ function HomeSessionSearch(props: {
</div>
</Show>
<label
class="relative z-20 flex h-9 w-full items-center gap-2 rounded-[6px] py-1 pl-3 pr-2 text-v2-icon-icon-muted transition-[background-color,box-shadow] duration-[120ms] ease-in-out"
class="relative z-20 flex h-9 w-full items-center gap-2 rounded-[6px] bg-v2-background-bg-layer-02 py-1 pl-3 pr-2 text-v2-icon-icon-muted transition-[background-color,box-shadow] duration-[120ms] ease-in-out"
classList={{
"bg-v2-background-bg-layer-03 focus-within:bg-v2-background-bg-layer-03 focus-within:shadow-[0_0_0_0.5px_var(--v2-border-border-focus),var(--v2-elevation-raised)]":
!props.open,
"bg-transparent shadow-[0_0_0_0.5px_var(--v2-border-border-focus)]": props.open,
"focus-within:shadow-[0_0_0_0.5px_var(--v2-border-border-focus),var(--v2-elevation-raised)]": !props.open,
"shadow-[0_0_0_0.5px_var(--v2-border-border-focus)]": props.open,
}}
>
<IconV2 name="magnifying-glass" />
@@ -1031,6 +1087,7 @@ function HomeSessionSearch(props: {
function HomeSessionSearchResultRow(props: {
record: HomeSessionRecord
showProjectName: boolean
server: ServerConnection.Key
activeServer: boolean
selected: boolean
@@ -1038,6 +1095,7 @@ function HomeSessionSearchResultRow(props: {
onSelect: (session: Session) => void
}) {
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
const showProjectName = () => props.showProjectName && props.record.projectName
const key = () => homeSessionSearchKey(props.record)
@@ -1064,11 +1122,11 @@ function HomeSessionSearchResultRow(props: {
/>
<div class="flex min-w-0 flex-1 items-center gap-1.5">
<span
class={`${HOME_SEARCH_RESULT_TITLE} ${props.record.projectName ? "max-w-[min(70%,480px)] flex-[0_1_auto]" : "flex-[1_1_auto]"}`}
class={`${HOME_SEARCH_RESULT_TITLE} ${showProjectName() ? "max-w-[min(70%,480px)] flex-[0_1_auto]" : "flex-[1_1_auto]"}`}
>
{title()}
</span>
<Show when={props.record.projectName}>
<Show when={showProjectName()}>
<span class={HOME_SEARCH_RESULT_META}>{props.record.projectName}</span>
</Show>
</div>
@@ -1079,7 +1137,7 @@ function HomeSessionSearchResultRow(props: {
function HomeSessionGroupHeader(props: { title: string; onNewSession?: () => void }) {
const language = useLanguage()
return (
<div class="flex h-7 min-w-0 items-center justify-between pl-4 pr-2">
<div class="flex h-7 min-w-0 items-center justify-between pl-[18px] pr-2">
<div class={HOME_SECTION_LABEL}>{props.title}</div>
<Show when={props.onNewSession}>
{(onNewSession) => (
@@ -1101,36 +1159,58 @@ function HomeSessionGroupHeader(props: { title: string; onNewSession?: () => voi
function HomeSessionRow(props: {
record: HomeSessionRecord
showProjectName: boolean
server: ServerConnection.Key
activeServer: boolean
onClick: () => void
openSession: (session: Session) => void
archiveSession: (session: Session) => Promise<void>
}) {
const language = useLanguage()
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
const showProjectName = () => props.showProjectName && props.record.projectName
return (
<button
type="button"
data-component="home-session-row"
class={`${HOME_ROW} h-10 gap-2 px-6 py-3 pl-4`}
onClick={props.onClick}
>
<HomeSessionLeading
project={props.record.project}
session={props.record.session}
server={props.server}
activeServer={props.activeServer}
/>
<span
class={`min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-base [font-weight:530] ${props.record.projectName ? "max-w-[min(70%,480px)] flex-[0_1_auto]" : "flex-[1_1_auto]"}`}
<div class="group/session relative flex h-10 min-w-0 items-center rounded-[6px]">
<button
type="button"
data-component="home-session-row"
class={`${HOME_ROW} h-10 min-w-0 flex-1 gap-2 py-3 pl-3 pr-10`}
onClick={() => props.openSession(props.record.session)}
>
{title()}
</span>
<Show when={props.record.projectName}>
<span class="min-w-0 flex-[1_1_auto] overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-muted [font-weight:440]">
{props.record.projectName}
<HomeSessionLeading
project={props.record.project}
session={props.record.session}
server={props.server}
activeServer={props.activeServer}
/>
<span
class={`min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-base [font-weight:530] ${showProjectName() ? "max-w-[min(70%,480px)] flex-[0_1_auto]" : "flex-[1_1_auto]"}`}
>
{title()}
</span>
</Show>
</button>
<Show when={showProjectName()}>
<span class="min-w-0 flex-[1_1_auto] overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-muted [font-weight:440]">
{props.record.projectName}
</span>
</Show>
</button>
<div class="absolute right-1.5 top-1/2 flex -translate-y-1/2 items-center gap-1 opacity-0 transition-opacity group-hover/session:opacity-100 focus-within:opacity-100">
<TooltipV2 class="flex shrink-0 items-center" placement="bottom" value={language.t("common.archive")}>
<IconButtonV2
data-action="home-session-archive"
variant="ghost-muted"
size="large"
icon={<IconV2 name="archive" />}
aria-label={language.t("common.archive")}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
void props.archiveSession(props.record.session)
}}
/>
</TooltipV2>
</div>
</div>
)
}