Compare commits

...

5 Commits

Author SHA1 Message Date
Brendan Allan 1adc655cc4 fix(app): gate legacy server features 2026-07-24 12:08:08 +08:00
Brendan Allan b751e26565 refactor(app): detach home views from controllers 2026-07-24 12:02:42 +08:00
Brendan Allan 1612baf254 refactor(app): split home section views 2026-07-24 11:48:19 +08:00
Brendan Allan ef8f238c49 refactor(app): isolate legacy home 2026-07-24 11:42:30 +08:00
Brendan Allan 93e186b5df refactor(app): split home view controllers 2026-07-24 11:38:38 +08:00
21 changed files with 2413 additions and 2066 deletions
+52 -11
View File
@@ -47,7 +47,7 @@ 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"
import { LayoutProvider } from "@/context/layout"
import { currentRoute, LayoutProvider } from "@/context/layout"
import { ModelsProvider } from "@/context/models"
import { NotificationProvider } from "@/context/notification"
import { PermissionProvider } from "@/context/permission"
@@ -67,7 +67,8 @@ import { legacySessionHref, legacySessionServer, requireServerKey, sessionHref }
import { createSessionLineage } from "@/pages/session/session-lineage"
import { SessionPage, SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session"
import { NewHome, LegacyHome } from "@/pages/home"
import { NewHome } from "@/pages/home"
import { LegacyHome } from "@/pages/home/legacy-home"
const NewSession = lazy(() => import("@/pages/new-session"))
@@ -236,6 +237,44 @@ function UiI18nBridge(props: ParentProps) {
return <I18nProvider value={{ locale: language.intl, t: language.t }}>{props.children}</I18nProvider>
}
function LayoutCompatibility(props: ParentProps) {
const global = useGlobal()
const location = useLocation()
const server = useServer()
const settings = useSettings()
const tabs = useTabs()
const connection = createMemo(() => {
const route = currentRoute(location.pathname, location.search)
if (route.type === "draft" && !tabs.ready()) return
const key =
route.type === "draft"
? tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === route.draftID)?.server
: route.type === "home"
? undefined
: route.server
if (!key) return server.current
return global.servers.list().find((item) => ServerConnection.key(item) === key)
})
const protocol = createMemo(() => {
const value = connection()
if (!value) return
return global.ensureServerCtx(value).sdk.protocolKind()
})
createEffect(() => {
const value = protocol()
if (!value) return
settings.general.setNewLayoutRequired(value === "v2")
})
const ready = createMemo(() => {
const value = protocol()
if (!value) return false
return settings.general.newLayoutRequired() === (value === "v2")
})
return <Show when={ready()}>{props.children}</Show>
}
declare global {
interface Window {
__OPENCODE__?: {
@@ -557,15 +596,17 @@ export function AppInterface(props: {
component={props.router ?? Router}
root={(routerProps) => (
<TabsProvider>
<PermissionProvider>
<NotificationProvider>
<ServerShell>
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
<NewAppLayout serverScoped={props.serverScoped}>{routerProps.children}</NewAppLayout>
</Show>
</ServerShell>
</NotificationProvider>
</PermissionProvider>
<LayoutCompatibility>
<PermissionProvider>
<NotificationProvider>
<ServerShell>
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
<NewAppLayout serverScoped={props.serverScoped}>{routerProps.children}</NewAppLayout>
</Show>
</ServerShell>
</NotificationProvider>
</PermissionProvider>
</LayoutCompatibility>
</TabsProvider>
)}
>
@@ -131,6 +131,7 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
const saveMutation = useMutation(() => ({
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => {
if ((await serverSDK().protocol) !== "v1") throw new Error("Custom providers are unavailable on this server")
const disabledProviders = serverSync().data.config.disabled_providers ?? []
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
@@ -15,9 +15,47 @@ export const ServerRowMenu: Component<{
}> = (props) => {
const language = useLanguage()
const key = ServerConnection.key(props.server)
const builtin = ServerConnection.builtin(props.server)
const isDefault = () => props.controller.defaultKey() === key
return (
<ServerRowMenuView
server={props.server}
labels={serverMenuLabels(language)}
canDefault={props.controller.canDefault()}
isDefault={props.controller.defaultKey() === key}
onEdit={props.onEdit}
onSetDefault={() => props.controller.setDefault(key)}
onRemoveDefault={() => props.controller.setDefault(null)}
onRemove={() => props.controller.handleRemove(key)}
open={props.open}
onOpenChange={props.onOpenChange}
/>
)
}
export function serverMenuLabels(language: ReturnType<typeof useLanguage>) {
return {
more: language.t("common.moreOptions"),
server: language.t("settings.section.server"),
edit: language.t("dialog.server.menu.edit"),
default: language.t("dialog.server.menu.default"),
defaultRemove: language.t("dialog.server.menu.defaultRemove"),
delete: language.t("dialog.server.menu.delete"),
}
}
export const ServerRowMenuView: Component<{
server: ServerConnection.Any
labels: ReturnType<typeof serverMenuLabels>
canDefault: boolean
isDefault: boolean
onEdit: (server: ServerConnection.Http) => void
onSetDefault: () => void
onRemoveDefault: () => void
onRemove: () => void
open?: boolean
onOpenChange?: (open: boolean) => void
}> = (props) => {
const builtin = () => ServerConnection.builtin(props.server)
const httpServer = () => (props.server.type === "http" ? props.server : undefined)
return (
<MenuV2 gutter={6} modal={false} placement="bottom-end" open={props.open} onOpenChange={props.onOpenChange}>
<MenuV2.Trigger
@@ -25,31 +63,30 @@ export const ServerRowMenu: Component<{
variant="ghost-muted"
size="small"
icon={<IconV2 name="outline-dots" />}
aria-label={language.t("common.moreOptions")}
aria-label={props.labels.more}
/>
<MenuV2.Portal>
<MenuV2.Content>
<MenuV2.Group>
<MenuV2.GroupLabel>{language.t("settings.section.server")}</MenuV2.GroupLabel>
<MenuV2.GroupLabel>{props.labels.server}</MenuV2.GroupLabel>
<MenuV2.Item
disabled={builtin || props.server.type !== "http"}
onSelect={() => props.onEdit(props.server as ServerConnection.Http)}
disabled={builtin() || !httpServer()}
onSelect={() => {
const server = httpServer()
if (server) props.onEdit(server)
}}
>
{language.t("dialog.server.menu.edit")}
{props.labels.edit}
</MenuV2.Item>
<Show when={props.controller.canDefault() && !isDefault()}>
<MenuV2.Item onSelect={() => props.controller.setDefault(key)}>
{language.t("dialog.server.menu.default")}
</MenuV2.Item>
<Show when={props.canDefault && !props.isDefault}>
<MenuV2.Item onSelect={props.onSetDefault}>{props.labels.default}</MenuV2.Item>
</Show>
<Show when={props.controller.canDefault() && isDefault()}>
<MenuV2.Item onSelect={() => props.controller.setDefault(null)}>
{language.t("dialog.server.menu.defaultRemove")}
</MenuV2.Item>
<Show when={props.canDefault && props.isDefault}>
<MenuV2.Item onSelect={props.onRemoveDefault}>{props.labels.defaultRemove}</MenuV2.Item>
</Show>
<MenuV2.Separator />
<MenuV2.Item disabled={builtin} onSelect={() => props.controller.handleRemove(key)}>
{language.t("dialog.server.menu.delete")}
<MenuV2.Item disabled={builtin()} onSelect={props.onRemove}>
{props.labels.delete}
</MenuV2.Item>
</MenuV2.Group>
</MenuV2.Content>
@@ -13,7 +13,7 @@ import { useLanguage } from "@/context/language"
import { usePermission } from "@/context/permission"
import { usePlatform, type DisplayBackend } from "@/context/platform"
import { useServerSync } from "@/context/server-sync"
import { useServerSDK } from "@/context/server-sdk"
import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
import { useUpdaterAction } from "./updater-action"
import {
monoDefault,
@@ -125,10 +125,12 @@ export const SettingsGeneral: Component = () => {
const serverSync = useServerSync()
const serverSdk = useServerSDK()
const protocol = useServerProtocol()
const [shells] = createResource(
() =>
serverSdk()
() => (protocol() === "v1" ? serverSdk() : undefined),
(sdk) =>
sdk
.client.pty.shells()
.then((res) => res.data ?? [])
.catch(() => [] as ShellOption[]),
@@ -322,27 +324,29 @@ export const SettingsGeneral: Component = () => {
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.shell.title")}
description={language.t("settings.general.row.shell.description")}
>
<Select
data-action="settings-shell"
options={shellOptions()}
current={shellOptions().find((o) => o.value === currentShell()) ?? autoOption}
value={(o) => o.id}
label={(o) => o.label}
onSelect={(option) => {
if (!option) return
if (option.value === currentShell()) return
serverSync().updateConfig({ shell: option.value })
}}
variant="secondary"
size="small"
triggerVariant="settings"
triggerStyle={{ "min-width": "180px" }}
/>
</SettingsRow>
<Show when={protocol() === "v1"}>
<SettingsRow
title={language.t("settings.general.row.shell.title")}
description={language.t("settings.general.row.shell.description")}
>
<Select
data-action="settings-shell"
options={shellOptions()}
current={shellOptions().find((o) => o.value === currentShell()) ?? autoOption}
value={(o) => o.id}
label={(o) => o.label}
onSelect={(option) => {
if (!option) return
if (option.value === currentShell()) return
serverSync().updateConfig({ shell: option.value })
}}
variant="secondary"
size="small"
triggerVariant="settings"
triggerStyle={{ "min-width": "180px" }}
/>
</SettingsRow>
</Show>
<SettingsRow
title={language.t("settings.general.row.reasoningSummaries.title")}
@@ -6,7 +6,7 @@ import { showToast } from "@/utils/toast"
import { popularProviders, useProviders } from "@/hooks/use-providers"
import { createMemo, type Component, For, Show } from "solid-js"
import { useLanguage } from "@/context/language"
import { useServerSDK } from "@/context/server-sdk"
import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { DialogConnectProvider, useProviderConnectController } from "./dialog-connect-provider"
import { DialogCustomProvider } from "./dialog-custom-provider"
@@ -39,6 +39,7 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
const dialog = useDialog()
const language = useLanguage()
const serverSDK = useServerSDK()
const protocol = useServerProtocol()
const serverSync = useServerSync()
const providers = useProviders()
const providerConnect = useProviderConnectController({ onBack: props.onBack })
@@ -83,7 +84,8 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
return language.t("settings.providers.tag.other")
}
const canDisconnect = (item: ProviderItem) => source(item) !== "env"
const canDisconnect = (item: ProviderItem) =>
source(item) !== "env" && (protocol() === "v1" || !isConfigCustom(item.id))
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
@@ -96,6 +98,7 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
}
const disableProvider = async (providerID: string, name: string) => {
if (protocol() !== "v1") return
const before = serverSync().data.config.disabled_providers ?? []
const next = before.includes(providerID) ? before : [...before, providerID]
serverSync().set("config", "disabled_providers", next)
@@ -218,31 +221,33 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
)}
</For>
<div
class="flex items-center justify-between gap-4 min-h-16 border-b border-border-weak-base last:border-none flex-wrap py-3"
data-component="custom-provider-section"
>
<div class="flex flex-col min-w-0">
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
<ProviderIcon id="synthetic" class="size-5 shrink-0 icon-strong-base" />
<span class="text-14-medium text-text-strong">{language.t("provider.custom.title")}</span>
<Tag>{language.t("settings.providers.tag.custom")}</Tag>
</div>
<span class="text-12-regular text-text-weak pl-8">
{language.t("settings.providers.custom.description")}
</span>
</div>
<Button
size="large"
variant="secondary"
icon="plus-small"
onClick={() => {
dialog.show(() => <DialogCustomProvider onBack={dialog.close} />)
}}
<Show when={protocol() === "v1"}>
<div
class="flex items-center justify-between gap-4 min-h-16 border-b border-border-weak-base last:border-none flex-wrap py-3"
data-component="custom-provider-section"
>
{language.t("common.connect")}
</Button>
</div>
<div class="flex flex-col min-w-0">
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
<ProviderIcon id="synthetic" class="size-5 shrink-0 icon-strong-base" />
<span class="text-14-medium text-text-strong">{language.t("provider.custom.title")}</span>
<Tag>{language.t("settings.providers.tag.custom")}</Tag>
</div>
<span class="text-12-regular text-text-weak pl-8">
{language.t("settings.providers.custom.description")}
</span>
</div>
<Button
size="large"
variant="secondary"
icon="plus-small"
onClick={() => {
dialog.show(() => <DialogCustomProvider onBack={dialog.close} />)
}}
>
{language.t("common.connect")}
</Button>
</div>
</Show>
</SettingsList>
<Button
@@ -10,7 +10,7 @@ import { useLanguage } from "@/context/language"
import { usePermission } from "@/context/permission"
import { usePlatform } from "@/context/platform"
import { useServerSync } from "@/context/server-sync"
import { useServerSDK } from "@/context/server-sdk"
import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
import { useUpdaterAction } from "../updater-action"
import {
monoDefault,
@@ -92,6 +92,7 @@ export const SettingsGeneralV2: Component<{
const settings = useSettings()
const serverSync = useServerSync()
const serverSdk = useServerSDK()
const protocol = useServerProtocol()
const mobile = createMediaQuery("(max-width: 767px)")
const updater = useUpdaterAction()
@@ -122,8 +123,9 @@ export const SettingsGeneralV2: Component<{
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
const [shells] = createResource(
() =>
serverSdk()
() => (protocol() === "v1" ? serverSdk() : undefined),
(sdk) =>
sdk
.client.pty.shells()
.then((res) => res.data ?? [])
.catch(() => [] as ShellOption[]),
@@ -281,26 +283,28 @@ export const SettingsGeneralV2: Component<{
</div>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.shell.title")}
description={language.t("settings.general.row.shell.description")}
>
<SelectV2
appearance="inline"
data-action="settings-shell"
options={shellOptions()}
current={shellOptions().find((o) => o.value === currentShell()) ?? autoOption}
placement="bottom-end"
gutter={6}
value={(o) => o.id}
label={(o) => o.label}
onSelect={(option) => {
if (!option) return
if (option.value === currentShell()) return
serverSync().updateConfig({ shell: option.value })
}}
/>
</SettingsRowV2>
<Show when={protocol() === "v1"}>
<SettingsRowV2
title={language.t("settings.general.row.shell.title")}
description={language.t("settings.general.row.shell.description")}
>
<SelectV2
appearance="inline"
data-action="settings-shell"
options={shellOptions()}
current={shellOptions().find((o) => o.value === currentShell()) ?? autoOption}
placement="bottom-end"
gutter={6}
value={(o) => o.id}
label={(o) => o.label}
onSelect={(option) => {
if (!option) return
if (option.value === currentShell()) return
serverSync().updateConfig({ shell: option.value })
}}
/>
</SettingsRowV2>
</Show>
<SettingsRowV2
title={language.t("settings.general.row.reasoningSummaries.title")}
@@ -692,7 +696,9 @@ export const SettingsGeneralV2: Component<{
<div class="settings-v2-tab-body">
<Show when={settings.general.layoutTransitionAvailable()}>
<InterfaceSection />
<Show when={!settings.general.newLayoutRequired()}>
<InterfaceSection />
</Show>
</Show>
<Show when={settings.general.newInterfaceNoticeVisible()}>
@@ -6,7 +6,7 @@ import { showToast } from "@/utils/toast"
import { popularProviders, useProviders } from "@/hooks/use-providers"
import { createMemo, type Component, For, Show } from "solid-js"
import { useLanguage } from "@/context/language"
import { useServerSDK } from "@/context/server-sdk"
import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { DialogConnectProvider, useProviderConnectController } from "../dialog-connect-provider"
import { DialogCustomProvider } from "../dialog-custom-provider"
@@ -33,6 +33,7 @@ export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) =
const dialog = useDialog()
const language = useLanguage()
const serverSdk = useServerSDK()
const protocol = useServerProtocol()
const serverSync = useServerSync()
const providers = useProviders()
const providerConnect = useProviderConnectController({ onBack: props.onBack })
@@ -77,7 +78,8 @@ export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) =
return language.t("settings.providers.tag.other")
}
const canDisconnect = (item: ProviderItem) => source(item) !== "env"
const canDisconnect = (item: ProviderItem) =>
source(item) !== "env" && (protocol() === "v1" || !isConfigCustom(item.id))
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
@@ -90,6 +92,7 @@ export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) =
}
const disableProvider = async (providerID: string, name: string) => {
if (protocol() !== "v1") return
const before = serverSync().data.config.disabled_providers ?? []
const next = before.includes(providerID) ? before : [...before, providerID]
serverSync().set("config", "disabled_providers", next)
@@ -218,33 +221,35 @@ export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) =
)}
</For>
<div class="settings-v2-provider-row" data-component="custom-provider-section">
<div class="settings-v2-provider-lead">
<ProviderIcon
id="synthetic"
width={PROVIDER_ICON_SIZE}
height={PROVIDER_ICON_SIZE}
class="settings-v2-provider-icon shrink-0"
/>
<div class="settings-v2-provider-copy">
<div class="settings-v2-provider-main">
<span class="settings-v2-provider-name">{language.t("provider.custom.title")}</span>
<Tag>{language.t("settings.providers.tag.custom")}</Tag>
<Show when={protocol() === "v1"}>
<div class="settings-v2-provider-row" data-component="custom-provider-section">
<div class="settings-v2-provider-lead">
<ProviderIcon
id="synthetic"
width={PROVIDER_ICON_SIZE}
height={PROVIDER_ICON_SIZE}
class="settings-v2-provider-icon shrink-0"
/>
<div class="settings-v2-provider-copy">
<div class="settings-v2-provider-main">
<span class="settings-v2-provider-name">{language.t("provider.custom.title")}</span>
<Tag>{language.t("settings.providers.tag.custom")}</Tag>
</div>
<p class="settings-v2-provider-description">{language.t("settings.providers.custom.description")}</p>
</div>
<p class="settings-v2-provider-description">{language.t("settings.providers.custom.description")}</p>
</div>
<ButtonV2
size="normal"
variant="neutral"
icon="plus"
onClick={() => {
dialog.show(() => <DialogCustomProvider onBack={dialog.close} />)
}}
>
{language.t("common.connect")}
</ButtonV2>
</div>
<ButtonV2
size="normal"
variant="neutral"
icon="plus"
onClick={() => {
dialog.show(() => <DialogCustomProvider onBack={dialog.close} />)
}}
>
{language.t("common.connect")}
</ButtonV2>
</div>
</Show>
</SettingsListV2>
<button type="button" class="settings-v2-providers-view-all" onClick={() => connect()}>
@@ -16,6 +16,7 @@ import { type ServerHealth } from "@/utils/server-health"
import { useGlobal } from "@/context/global"
import { useSettings } from "@/context/settings"
import { useMcpToggle } from "@/context/mcp"
import { useServerProtocol } from "@/context/server-sdk"
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
const parts = value.split(file)
@@ -257,6 +258,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
const language = useLanguage()
const navigate = useNavigate()
const settings = useSettings()
const protocol = useServerProtocol()
const fail = (err: unknown) => {
showToast({
@@ -315,10 +317,12 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
{lspCount() > 0 ? `${lspCount()} ` : ""}
{language.t("status.popover.tab.lsp")}
</Tabs.Trigger>
<Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular">
{pluginCount() > 0 ? `${pluginCount()} ` : ""}
{language.t("status.popover.tab.plugins")}
</Tabs.Trigger>
<Show when={protocol() === "v1"}>
<Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular">
{pluginCount() > 0 ? `${pluginCount()} ` : ""}
{language.t("status.popover.tab.plugins")}
</Tabs.Trigger>
</Show>
</Tabs.List>
{!settings.general.newLayoutDesigns() && (
@@ -478,25 +482,27 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
</div>
</Tabs.Content>
<Tabs.Content value="plugins">
<div class="flex flex-col px-2 pb-2">
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
<Show
when={plugins().length > 0}
fallback={<div class="text-14-regular text-text-base text-center my-auto">{pluginEmpty()}</div>}
>
<For each={plugins()}>
{(plugin) => (
<div class="flex items-center gap-2 w-full px-2 py-1">
<div class="size-1.5 rounded-full shrink-0 bg-icon-success-base" />
<span class="text-14-regular text-text-base truncate">{plugin}</span>
</div>
)}
</For>
</Show>
<Show when={protocol() === "v1"}>
<Tabs.Content value="plugins">
<div class="flex flex-col px-2 pb-2">
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
<Show
when={plugins().length > 0}
fallback={<div class="text-14-regular text-text-base text-center my-auto">{pluginEmpty()}</div>}
>
<For each={plugins()}>
{(plugin) => (
<div class="flex items-center gap-2 w-full px-2 py-1">
<div class="size-1.5 rounded-full shrink-0 bg-icon-success-base" />
<span class="text-14-regular text-text-base truncate">{plugin}</span>
</div>
)}
</For>
</Show>
</div>
</div>
</div>
</Tabs.Content>
</Tabs.Content>
</Show>
</Tabs>
</div>
)
+1 -1
View File
@@ -126,7 +126,7 @@ const normalizeStoredSessionTabs = (key: string, tabs: SessionTabs) => {
}
}
const currentRoute = (pathname: string, search: string): LayoutRoute => {
export const currentRoute = (pathname: string, search: string): LayoutRoute => {
const parts = pathname.split("/").filter(Boolean)
if (parts.length === 0) return { type: "home" }
+1
View File
@@ -211,6 +211,7 @@ function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }
)
function enableConfiguredDirectory(directory: string) {
if (input.sdk.protocolKind() !== "v1") return
if (meta.disposed || !ready()) return
const [childStore] = input.sync.child(directory)
if (childStore.config.permission !== "allow") return
+12 -1
View File
@@ -3,7 +3,7 @@ import type { Event } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { makeEventListener } from "@solid-primitives/event-listener"
import { type Accessor, batch, createMemo, onCleanup, onMount } from "solid-js"
import { type Accessor, batch, createMemo, createResource, onCleanup, onMount } from "solid-js"
import { createApiForServer, createSdkForServer, type ServerApi } from "@/utils/server"
import { useLanguage } from "./language"
import { usePlatform } from "./platform"
@@ -169,6 +169,7 @@ type ServerSDKBase = {
server: ServerConnection.Any
scope: ServerScope
protocol: Promise<ServerProtocol>
protocolKind: Accessor<ServerProtocol | undefined>
url: string
client: ReturnType<typeof createSdkForServer>
api: CompatibleApi
@@ -205,6 +206,10 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
server: server.http,
})
const protocol = detectServerProtocol(server.http, platform.fetch ?? globalThis.fetch)
const [protocolKind] = createResource(
() => protocol,
(value) => value,
)
const emitter = createGlobalEmitter<{
[key: string]: ServerEvent
}>()
@@ -347,6 +352,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
server,
scope,
protocol,
protocolKind,
url: server.http.url,
client: sdk,
api,
@@ -394,6 +400,11 @@ export const { use: useServerSDK, provider: ServerSDKProvider } = createSimpleCo
},
})
export function useServerProtocol() {
const serverSDK = useServerSDK()
return createMemo(() => serverSDK().protocolKind())
}
type SDKEventMap = {
[key in Event["type"]]: Extract<ServerEvent, { type: key }>
}
+7 -2
View File
@@ -230,6 +230,7 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
migrationApplied: false,
previous: undefined as string | undefined,
})
const [newLayoutRequired, setNewLayoutRequired] = createSignal(false)
const showFileTree = withFallback(() => store.general?.showFileTree, defaultSettings.general.showFileTree)
const showSearch = withFallback(() => store.general?.showSearch, defaultSettings.general.showSearch)
const showStatus = withFallback(() => store.general?.showStatus, defaultSettings.general.showStatus)
@@ -250,7 +251,7 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
const layoutTransition = createMemo(() =>
layoutTransitionState(!!sunset, layoutTransitionEligible(), oldInterfaceRetired(), newInterfaceNoticeDismissed()),
)
const newLayoutDesigns = createMemo(() => {
const preferredNewLayout = createMemo(() => {
if (layoutUpgrade()) return true
if (!ready() && !oldInterfaceRetired()) return legacyNewLayoutDesignsDefault
if (!layoutTransitionClassified()) {
@@ -266,6 +267,7 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
layoutTransitionEligible() ? legacyNewLayoutDesignsDefault : newLayoutDesignsDefault,
)
})
const newLayoutDesigns = createMemo(() => newLayoutRequired() || preferredNewLayout())
const visible = (preference: () => boolean) => createMemo(() => !newLayoutDesigns() || preference())
if (sunset && !oldInterfaceRetired()) {
@@ -404,9 +406,12 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setStore("general", "mobileTitlebarPosition", value)
},
newLayoutDesigns,
newLayoutRequired,
setNewLayoutRequired,
setNewLayoutDesigns(value: boolean) {
if (newLayoutRequired() && !value) return
const next = oldInterfaceRetired() ? true : value
if (newLayoutDesigns() === next) return
if (preferredNewLayout() === next) return
setStore("general", "newLayoutDesigns", next)
if (typeof window !== "undefined") setTimeout(() => window.location.reload())
},
+85 -1908
View File
@@ -1,1926 +1,103 @@
import type { Session } from "@opencode-ai/sdk/v2/client"
import {
type ComponentProps,
createEffect,
createMemo,
createResource,
createRoot,
createSignal,
For,
Match,
on,
onCleanup,
onMount,
Show,
startTransition,
Switch,
} from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
import { createStore, produce } from "solid-js/store"
import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
import { isSortable, useSortable } from "@dnd-kit/solid/sortable"
import { AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/dom"
import { RestrictToVerticalAxis } from "@dnd-kit/abstract/modifiers"
import { RestrictToElement } from "@dnd-kit/dom/modifiers"
import { useQuery } from "@tanstack/solid-query"
import { Button } from "@opencode-ai/ui/button"
import { Logo } from "@opencode-ai/ui/logo"
import { Spinner } from "@opencode-ai/ui/spinner"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
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 HomeProjectSelection, type LocalProject } from "@/context/layout"
import { useNavigate } from "@solidjs/router"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { Icon } from "@opencode-ai/ui/icon"
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 { DialogSelectServer, useServerManagementController } from "@/components/dialog-select-server"
import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2"
import { ServerConnection, serverName, useServer } from "@/context/server"
import { sessionHasOpenTab, useTabs } from "@/context/tabs"
import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
import { useNotification } from "@/context/notification"
import {
closeHomeProject,
displayName,
errorMessage,
getProjectAvatarSource,
homeProjectDirectories,
projectForSession,
toggleHomeProjectSelection,
} from "@/pages/layout/helpers"
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
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 { shouldOpenSessionInBackground } from "./home-session-open"
import { showToast } from "@/utils/toast"
import { fileManagerApp } from "@/utils/file-manager"
import {
loadHomeSessionIndex,
retainHomeSessions,
type HomeSessionEvents,
} from "@/context/global-sync/home-session-index"
const HOME_SESSION_LIMIT = 64
const HOME_SESSION_HEADER_STICKY_TOP = 12
const HOME_SESSION_HEADER_TEXT_HEIGHT = 16
const HOME_SESSION_HEADER_FADE_DISTANCE = 16
function containHomeWheel(event: WheelEvent, viewport: HTMLElement) {
if (event.defaultPrevented || event.ctrlKey || !event.deltaY) return
if (!(event.target instanceof Element)) return
const scrollable = event.target.closest<HTMLElement>("[data-scrollable]")
if (
scrollable !== viewport &&
scrollable &&
(event.deltaY < 0
? scrollable.scrollTop > 0
: scrollable.scrollTop < scrollable.scrollHeight - scrollable.clientHeight)
)
return
event.preventDefault()
}
const SHOW_HOME_SESSION_ARCHIVE = false
const HOME_ROW_LAYOUT =
"flex min-w-0 w-full shrink-0 cursor-default items-center rounded-[6px] bg-transparent text-left transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out focus-visible:outline-none"
const HOME_ROW_BASE = `${HOME_ROW_LAYOUT} border-0`
const HOME_ROW = `${HOME_ROW_BASE} [font-weight:530] text-v2-text-text-muted hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover`
const HOME_PROJECT_NAV_LABEL = "min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap"
const HOME_PROJECT_NAV_ROW = `${HOME_ROW_LAYOUT} h-7 gap-2 px-1.5 [font-weight:440] text-v2-text-text-muted hover:bg-v2-background-bg-layer-01 hover:text-v2-text-text-base hover:[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)] data-[selected]:bg-v2-background-bg-layer-03 data-[selected]:text-v2-text-text-base data-[selected]:[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)] data-[selected]:hover:bg-v2-background-bg-layer-03 focus-visible:bg-v2-background-bg-layer-01 focus-visible:text-v2-text-text-base focus-visible:[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)]`
const HOME_SECTION_LABEL = "text-v2-text-text-muted [font-weight:440]"
type HomeSessionRecord = {
session: Session
project: LocalProject
projectName: string
}
type HomeSessionGroup = {
id: "today" | "yesterday" | "older"
title: string
sessions: HomeSessionRecord[]
}
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-[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 =
"min-w-0 flex-[1_1_auto] overflow-hidden text-ellipsis whitespace-nowrap text-[13px] leading-4 tracking-[-0.04px] text-v2-text-text-muted [font-weight:440]"
let pendingHomeNavigation: { server: ServerConnection.Key; href: string } | undefined
function buildHomeSessionRecords(input: {
sessions: () => Session[]
projectDirectories: () => string[]
projects: () => LocalProject[]
projectByID: () => Map<string, LocalProject>
}) {
const directories = new Set(input.projectDirectories().map(pathKey))
const sessions = input.sessions().filter((session) => directories.has(pathKey(session.directory)))
return [...new Map(sessions.map((session) => [session.id, session] as const)).values()]
.sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
.flatMap((session) => {
const directory = pathKey(session.directory)
const project =
input
.projects()
.find(
(item) =>
pathKey(item.worktree) === directory || item.sandboxes?.some((sandbox) => pathKey(sandbox) === directory),
) ?? projectForSession(session, input.projects(), input.projectByID())
if (!project) return []
return {
session,
project,
projectName: displayName(project),
}
})
}
function matchesHomeSessionSearch(record: HomeSessionRecord, query: string) {
return `${record.session.title} ${record.projectName}`.toLowerCase().includes(query)
}
function homeSessionSearchKey(record: HomeSessionRecord) {
return `${pathKey(record.session.directory)}:${record.session.id}`
}
function useHomeSessionHeaderOpacity(groups: () => HomeSessionGroup[]) {
let viewport: HTMLDivElement | undefined
let content: HTMLDivElement | undefined
let positionFrame: number | undefined
let resizeObserver: ResizeObserver | undefined
let stickyTop = HOME_SESSION_HEADER_STICKY_TOP
const headerRefs = new Map<HomeSessionGroup["id"], HTMLDivElement>()
const headerOffsets = new Map<HomeSessionGroup["id"], number>()
const [state, setState] = createStore({
titleOpacity: {} as Partial<Record<HomeSessionGroup["id"], number>>,
})
createEffect(() => {
const items = groups()
const ids = new Set(items.map((group) => group.id))
headerRefs.forEach((_, id) => {
if (!ids.has(id)) headerRefs.delete(id)
})
headerOffsets.forEach((_, id) => {
if (!ids.has(id)) headerOffsets.delete(id)
})
if (items.length === 0) {
content = undefined
bindResizeObserver()
}
queuePositionUpdate()
})
onCleanup(() => {
if (positionFrame !== undefined) cancelAnimationFrame(positionFrame)
resizeObserver?.disconnect()
})
function setViewport(el: HTMLDivElement) {
viewport = el
bindResizeObserver()
queuePositionUpdate()
}
function setContentRef(el: HTMLDivElement) {
content = el
bindResizeObserver()
queuePositionUpdate()
}
function setHeaderRef(id: HomeSessionGroup["id"], el: HTMLDivElement) {
headerRefs.set(id, el)
queuePositionUpdate()
}
function queuePositionUpdate() {
if (typeof requestAnimationFrame === "undefined") {
updatePositionCache()
return
}
if (positionFrame !== undefined) return
positionFrame = requestAnimationFrame(() => {
positionFrame = undefined
updatePositionCache()
})
}
function updatePositionCache() {
if (!viewport) return
const header = groups()
.map((group) => headerRefs.get(group.id))
.find((el) => el !== undefined)
if (header && typeof getComputedStyle === "function") {
const top = Number.parseFloat(getComputedStyle(header).top)
if (Number.isFinite(top)) stickyTop = top
}
groups().forEach((group) => {
const el = headerRefs.get(group.id)
if (!el) return
headerOffsets.set(group.id, el.offsetTop)
})
update(viewport.scrollTop)
}
function update(scrollTop: number) {
const items = groups()
items.forEach((group, index) => {
const nextOffset = items
.slice(index + 1)
.map((item) => headerOffsets.get(item.id))
.find((offset) => offset !== undefined)
const fadeEnd = stickyTop + HOME_SESSION_HEADER_TEXT_HEIGHT
const nextTop = nextOffset === undefined ? undefined : nextOffset - scrollTop
const opacity =
nextTop === undefined ? 1 : Math.max(0, Math.min(1, (nextTop - fadeEnd) / HOME_SESSION_HEADER_FADE_DISTANCE))
setState("titleOpacity", group.id, Math.round(opacity * 1000) / 1000)
})
}
function titleOpacity(id: HomeSessionGroup["id"]) {
return state.titleOpacity[id] ?? 1
}
function bindResizeObserver() {
resizeObserver?.disconnect()
if (typeof ResizeObserver === "undefined") return
resizeObserver = new ResizeObserver(() => queuePositionUpdate())
if (viewport) resizeObserver.observe(viewport)
if (content) resizeObserver.observe(content)
}
return { setViewport, setContentRef, setHeaderRef, update, titleOpacity }
}
// Middle-click or Cmd+click on macOS (Ctrl+click elsewhere) opens a session
// tab in the background without navigating, matching browser conventions.
function isBackgroundOpen(event: MouseEvent) {
return shouldOpenSessionInBackground({
button: event.button,
mac: typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform),
meta: event.metaKey,
ctrl: event.ctrlKey,
shift: event.shiftKey,
alt: event.altKey,
})
}
type OpenSessionOptions = { background?: boolean }
import { createHomeProjectsController } from "./home/home-projects-controller"
import { HomeProjectsView, HomeUtilityNav } from "./home/home-projects-view"
import { createHomeSessionsController } from "./home/home-sessions-controller"
import { HomeSessionsView } from "./home/home-sessions-view"
export function NewHome() {
const sync = useServerSync()
const layout = useLayout()
const platform = usePlatform()
const pickDirectory = useDirectoryPicker()
const dialog = useDialog()
const navigate = useNavigate()
const server = useServer()
const language = useLanguage()
const global = useGlobal()
const tabs = useTabs()
const command = useCommand()
const notification = useNotification()
const marked = useMarked()
const openSettings = useSettingsCommand()
let focusSessionSearch: (() => void) | undefined
let sessionViewport: HTMLDivElement | undefined
const [sessionThumbTrack, setSessionThumbTrack] = createSignal<HTMLDivElement>()
const [sessionHoverTarget, setSessionHoverTarget] = createSignal<HTMLElement>()
const [state, setState] = createStore({
search: "",
searchFocused: false,
})
const selection = layout.home.selection
const focusedServer = createMemo(
() => global.servers.list().find((conn) => ServerConnection.key(conn) === selection().server) ?? server.current,
)
const focusedServerCtx = createMemo(() => {
const conn = focusedServer()
if (!conn) return
return global.ensureServerCtx(conn)
})
const focusedSync = () => focusedServerCtx()?.sync ?? sync()
const homeSessions = () => focusedSync().homeSessions
const projects = createMemo(() => focusedServerCtx()?.projects.list() ?? layout.projects.list())
const recentlyClosed = createMemo(
() => focusedServerCtx()?.projects.recentlyClosed() ?? layout.projects.recentlyClosed(),
)
const homedir = createMemo(() => focusedSync().data.path.home ?? "")
const selectedProject = createMemo(() => projects().find((project) => project.worktree === selection().directory))
const newSessionProject = createMemo(
() =>
selectedProject() ??
projects().find((project) => project.worktree === focusedServerCtx()?.projects.last()) ??
projects()[0],
)
const directories = (project: LocalProject) => [project.worktree, ...(project.sandboxes ?? [])]
const projectDirectories = createMemo(() => {
const project = selectedProject()
if (!project) return projects().flatMap(directories)
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 sessionEventLoad = useQuery(() => ({
queryKey: homeSessions().eventsKey,
queryFn: async (): Promise<HomeSessionEvents> => ({ sequence: 0, entries: [] }),
initialData: { sequence: 0, entries: [] } satisfies HomeSessionEvents,
enabled: false,
}))
const sessionLoad = useQuery(() => ({
queryKey: homeSessions().indexKey,
enabled: !!focusedServerCtx(),
queryFn: async ({ signal }) => {
const ctx = focusedServerCtx()
if (!ctx) return { sessions: [], eventSequence: 0 }
const cache = homeSessions()
const eventSequence = cache.eventSequence()
const index = await loadHomeSessionIndex(
(input, options) => ctx.sdk.client.v2.session.list(input, options),
eventSequence,
signal,
)
cache.complete(eventSequence)
return index
},
retry: false,
staleTime: 30_000,
refetchOnMount: true,
refetchOnReconnect: true,
}))
const projectByID = createMemo(
() => new Map(projects().flatMap((project) => (project.id ? [[project.id, project] as const] : []))),
)
const indexedSessions = createMemo(() =>
retainHomeSessions(
homeSessions().sessions(sessionLoad.data, sessionEventLoad.data),
HOME_SESSION_LIMIT,
Date.now(),
),
)
const allRecords = createMemo(() =>
buildHomeSessionRecords({
sessions: indexedSessions,
projectDirectories,
projects,
projectByID,
}),
)
const records = createMemo(() => allRecords().slice(0, HOME_SESSION_LIMIT))
const searchResults = createMemo(() => {
const query = search().toLowerCase()
if (!query) return []
return allRecords().filter((record) => matchesHomeSessionSearch(record, query))
})
const searchOpen = createMemo(() => state.searchFocused && search().length > 0)
const groups = createMemo(() => groupSessions(records(), language))
const sessionHeaderOpacity = useHomeSessionHeaderOpacity(groups)
const prefetched = new Set<string>()
createEffect(() => {
const ctx = focusedServerCtx()
if (!ctx) return
records()
.slice(0, 2)
.forEach((record) => {
const key = `${ServerConnection.key(focusedServer()!)}\0${record.session.id}`
if (prefetched.has(key)) return
prefetched.add(key)
createRoot((dispose) => {
try {
void ctx.sync.session
.sync(record.session.id)
.then(() => {
return Promise.all(
(ctx.sync.session.data.message[record.session.id] ?? []).flatMap((message) =>
(ctx.sync.session.data.part[message.id] ?? []).flatMap((part) => {
if (part.type !== "text" || !part.text) return []
return preloadMarkdown(part.text, part.id, marked)
}),
),
)
})
.catch(() => {})
.finally(dispose)
} catch {
dispose()
}
})
})
})
function setSelection(next: HomeProjectSelection) {
layout.home.setSelection(next)
}
function closeSearch() {
setState("search", "")
setState("searchFocused", false)
}
function selectSearchSession(session: Session, options?: OpenSessionOptions) {
openSession(session, options)
// Background opens keep the search visible so several results can be
// opened in a row.
if (!options?.background) closeSearch()
}
command.register("home", () => [
{
id: "command.palette",
title: language.t("command.palette"),
hidden: true,
onSelect: async () => {
const conn = focusedServer()
if (!conn) return
const ctx = global.ensureServerCtx(conn)
const { DialogHomeCommandPaletteV2 } = await import("@/components/dialog-command-palette-v2")
void dialog.show(() => (
<DialogHomeCommandPaletteV2
server={conn}
onSelectSession={(entry) => {
if (!entry.sessionID || !entry.directory || !entry.server) return
const sessionID = entry.sessionID
const server = entry.server
const directory = entry.project?.worktree ?? entry.directory
ctx.projects.open(directory)
ctx.projects.touch(directory)
void startTransition(() => {
const tab = tabs.addSessionTab({ server, sessionId: sessionID })
tabs.select(tab)
})
}}
/>
))
},
},
{
id: "home.sessions.search.focus",
title: searchPlaceholder(),
keybind: "mod+f",
hidden: true,
onSelect: () => focusSessionSearch?.(),
},
])
createEffect(() => {
const list = global.servers.list()
if (list.some((conn) => ServerConnection.key(conn) === selection().server)) return
const conn = list.find((conn) => ServerConnection.key(conn) === server.key) ?? list[0]
if (conn) setSelection({ server: ServerConnection.key(conn) })
})
createEffect(() => {
const pending = pendingHomeNavigation
if (!pending || pending.server !== server.key) return
pendingHomeNavigation = undefined
navigate(pending.href)
})
function focusServer(conn: ServerConnection.Any) {
setSelection({ server: ServerConnection.key(conn) })
}
function selectProject(conn: ServerConnection.Any, directory: string) {
const key = ServerConnection.key(conn)
if (global.servers.health[key]?.healthy === false) return
if (
!global
.ensureServerCtx(conn)
.projects.list()
.some((project) => project.worktree === directory)
)
return
setSelection(toggleHomeProjectSelection(selection(), key, directory))
}
function addProjects(conn: ServerConnection.Any, directories: string[]) {
const directory = directories[0]
if (!directory) return
const ctx = global.ensureServerCtx(conn)
directories.forEach(ctx.projects.open)
ctx.projects.touch(directory)
setSelection({ server: ServerConnection.key(conn), directory })
}
function openNewSession() {
const conn = focusedServer()
const project = newSessionProject()
if (!conn || !project) return
openProjectNewSession(conn, project.worktree)
}
function openProjectNewSession(conn: ServerConnection.Any, directory: string) {
const ctx = global.ensureServerCtx(conn)
ctx.projects.open(directory)
ctx.projects.touch(directory)
tabs.newDraft({ server: ServerConnection.key(conn), directory })
}
function editProject(conn: ServerConnection.Any, project: LocalProject) {
void import("@/components/dialog-edit-project-v2").then((x) => {
void dialog.show(() => <x.DialogEditProjectV2 server={conn} project={project} />)
})
}
function unseenCount(conn: ServerConnection.Any, project: LocalProject) {
const state = notification.ensureServerState(ServerConnection.key(conn))
return directories(project).reduce((total, directory) => total + state.project.unseenCount(directory), 0)
}
function clearNotifications(conn: ServerConnection.Any, project: LocalProject) {
const state = notification.ensureServerState(ServerConnection.key(conn))
directories(project)
.filter((directory) => state.project.unseenCount(directory) > 0)
.forEach((directory) => state.project.markViewed(directory))
}
function openSession(session: Session, options?: OpenSessionOptions) {
const directoryKey = pathKey(session.directory)
const project =
projects().find(
(item) =>
pathKey(item.worktree) === directoryKey ||
item.sandboxes?.some((sandbox) => pathKey(sandbox) === directoryKey),
) ?? projectForSession(session, projects(), projectByID())
const conn = focusedServer()
if (!conn) return
const directory = project?.worktree ?? session.directory
const ctx = global.ensureServerCtx(conn)
ctx.projects.open(directory)
if (options?.background) {
tabs.addSessionTab({ server: ServerConnection.key(conn), sessionId: session.id })
return
}
ctx.projects.touch(directory)
startTransition(() => {
const tab = tabs.addSessionTab({ server: ServerConnection.key(conn), sessionId: session.id })
tabs.select(tab)
})
}
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) {
if (global.servers.health[ServerConnection.key(conn)]?.healthy === false) return
function resolve(result: string | string[] | null) {
addProjects(conn, homeProjectDirectories(result))
}
pickDirectory({
server: conn,
title: language.t("command.project.open"),
multiple: true,
onSelect: resolve,
})
}
const projects = createHomeProjectsController()
const sessions = createHomeSessionsController(projects)
return (
<div class="rounded-[10px] shadow-[var(--v2-elevation-raised)] m-2 min-h-0 overflow-hidden bg-v2-background-bg-base self-stretch flex-1">
<ScrollView
class="h-full [container-type:size]"
thumbContainer={sessionThumbTrack}
thumbHoverTarget={sessionHoverTarget}
viewportRef={(el) => {
sessionViewport = el
sessionHeaderOpacity.setViewport(el)
}}
onScroll={(event) => sessionHeaderOpacity.update(event.currentTarget.scrollTop)}
onWheel={(event) => {
if (!sessionViewport) return
if (event.target instanceof Node && sessionViewport.contains(event.target)) return
containHomeWheel(event, sessionViewport)
}}
thumbContainer={sessions.scroll.thumbTrack}
thumbHoverTarget={sessions.scroll.hoverTarget}
viewportRef={sessions.scroll.setViewport}
onScroll={(event) => sessions.scroll.update(event.currentTarget.scrollTop)}
onWheel={sessions.scroll.containOuterWheel}
>
<div class="mx-auto grid min-h-full w-full max-w-[1080px] grid-rows-[auto_minmax(0,1fr)_auto] gap-4 px-3 lg:grid-cols-[280px_minmax(0,720px)] lg:grid-rows-1 lg:gap-8 lg:px-6">
<HomeProjectColumn
projects={projects()}
recentlyClosed={recentlyClosed()}
homedir={homedir()}
selected={selection()}
focusServer={focusServer}
selectProject={selectProject}
openNewSession={openProjectNewSession}
openRecentProject={(conn, directory) => addProjects(conn, [directory])}
chooseProject={(conn) => void chooseProject(conn)}
editProject={editProject}
closeProject={(conn, directory) => {
const next = closeHomeProject(
selection(),
ServerConnection.key(conn),
global.ensureServerCtx(conn).projects,
directory,
)
if (next) setSelection(next)
}}
clearNotifications={clearNotifications}
unseenCount={unseenCount}
openSettings={openSettings}
openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
language={language}
onWheel={(event) => {
if (sessionViewport) containHomeWheel(event, sessionViewport)
}}
<HomeProjectsView
language={projects.language}
servers={projects.servers}
projects={projects.projects}
recentlyClosed={projects.recentlyClosed}
selection={projects.selection}
homedir={projects.homedir}
serverHealth={projects.serverHealth}
projectsForServer={projects.projectsForServer}
collapsed={projects.collapsed}
menuOpen={projects.menuOpen}
canDefaultServer={projects.canDefaultServer}
isDefaultServer={projects.isDefaultServer}
canRevealProject={projects.canRevealProject}
fileManagerActionLabel={projects.fileManagerActionLabel}
unseenCount={projects.unseenCount}
serverMenuID={projects.serverMenuID}
projectMenuID={projects.projectMenuID}
onWheel={sessions.scroll.containWheel}
onChooseProject={projects.chooseProject}
onFocusServer={projects.focusServer}
onToggleCollapsed={projects.toggleCollapsed}
onEditServer={projects.openEditServer}
onSetDefaultServer={projects.setDefaultServer}
onRemoveServer={projects.removeServer}
onSetMenuOpen={projects.setMenuOpen}
onMoveProject={projects.moveProject}
onSelectProject={projects.selectProject}
onAddProjects={projects.addProjects}
onOpenProjectNewSession={projects.openProjectNewSession}
onEditProject={projects.editProject}
onRevealProject={projects.revealProject}
onClearNotifications={projects.clearNotifications}
onCloseProject={projects.closeProject}
onOpenSettings={projects.openSettings}
onOpenHelp={projects.openHelp}
/>
<HomeSessionsView
language={sessions.language}
groups={sessions.groups}
loading={sessions.loading}
showProjectName={sessions.showProjectName}
server={sessions.server}
canCreateSession={sessions.canCreateSession}
searchValue={sessions.search.value}
searchPlaceholder={sessions.search.placeholder}
searchOpen={sessions.search.open}
searchLoading={sessions.search.loading}
searchResults={sessions.search.results}
searchActive={sessions.search.active}
searchNoResultsLabel={sessions.search.noResultsLabel}
titleOpacity={sessions.header.titleOpacity}
isOpenTab={sessions.hasOpenTab}
onCreateSession={sessions.createSession}
onOpenSession={sessions.openSession}
onArchiveSession={sessions.archiveSession}
onSetHoverTarget={sessions.scroll.setHoverTarget}
onSetThumbTrack={sessions.scroll.setThumbTrack}
onSetContent={sessions.header.setContent}
onSetHeader={sessions.header.setHeader}
onWheel={sessions.scroll.containWheel}
onSetSearchRoot={sessions.search.setRoot}
onSetSearchInput={sessions.search.setInput}
onSetSearchList={sessions.search.setList}
onSearchFocus={sessions.search.focus}
onSearchInput={sessions.search.input}
onSearchClose={sessions.search.close}
onSearchMove={sessions.search.move}
onSearchSelectActive={sessions.search.selectActive}
onSearchHighlight={sessions.search.highlight}
onSearchSelect={sessions.search.select}
/>
<section
ref={setSessionHoverTarget}
class="min-h-0 min-w-0 flex-1 flex flex-col"
aria-label={language.t("sidebar.project.recentSessions")}
>
<div
class="sticky top-0 z-30 shrink-0 bg-v2-background-bg-base pb-3 pt-6 lg:pt-12"
onWheel={(event) => {
if (sessionViewport) containHomeWheel(event, sessionViewport)
}}
>
<HomeSessionSearch
value={state.search}
placeholder={searchPlaceholder()}
open={searchOpen()}
loading={sessionLoad.isLoading}
results={searchResults()}
showProjectName={!selectedProject()}
server={selection().server}
noResultsLabel={language.t("home.sessions.search.noResults", { query: search() })}
bindFocus={(focus) => {
focusSessionSearch = focus
}}
onInput={(value) => setState("search", value)}
onFocus={() => setState("searchFocused", true)}
onClose={closeSearch}
onSelect={selectSearchSession}
/>
<Show when={groups().length > 0 && newSessionProject()}>
<div class="pointer-events-none absolute right-0 top-[84px] z-20 flex lg:top-[108px]">
<ButtonV2
data-action="home-new-session"
variant="ghost-muted"
size="normal"
icon="edit"
class="pointer-events-auto h-7 px-2 [font-weight:530]"
onClick={openNewSession}
>
{language.t("command.session.new")}
</ButtonV2>
</div>
</Show>
</div>
{/* Sticky chrome for the portaled session scrollbar — matches old sessions ScrollView bounds */}
<div class="pointer-events-none sticky top-[84px] z-40 h-0 -mr-3 lg:top-[108px]">
<div
ref={setSessionThumbTrack}
data-component="home-session-scroll-track"
class="relative ml-auto h-[calc(100cqh-84px)] w-3 lg:h-[calc(100cqh-108px)]"
/>
</div>
<div class="-mr-3 min-h-[calc(100cqh-72px)] lg:min-h-[calc(100cqh-96px)]">
<Show
when={!sessionLoad.isLoading}
fallback={
<div class="pt-3">
<HomeSessionSkeleton label={language.t("common.loading")} />
</div>
}
>
<Show
when={groups().length > 0}
fallback={<HomeSessionsEmpty onNewSession={newSessionProject() ? openNewSession : undefined} />}
>
<div ref={sessionHeaderOpacity.setContentRef} class="flex flex-col pt-3 pr-3 pb-16">
<For each={groups()}>
{(group, index) => (
<>
<HomeSessionGroupHeader
title={group.title}
titleOpacity={sessionHeaderOpacity.titleOpacity(group.id)}
ref={(el) => sessionHeaderOpacity.setHeaderRef(group.id, el)}
elevated={index() === 0}
/>
<div
class={`flex min-w-0 flex-col gap-px pt-4 ${index() === groups().length - 1 ? "" : "mb-6"}`}
>
<For each={group.sessions}>
{(record) => (
<HomeSessionRow
record={record}
showProjectName={!selectedProject()}
server={selection().server}
openSession={openSession}
archiveSession={archiveSession}
/>
)}
</For>
</div>
</>
)}
</For>
</div>
</Show>
</Show>
</div>
</section>
<HomeUtilityNav
class="flex lg:hidden"
openSettings={openSettings}
openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
language={language}
onOpenSettings={projects.openSettings}
onOpenHelp={projects.openHelp}
language={projects.language}
/>
</div>
</ScrollView>
</div>
)
}
function HomeProjectColumn(props: {
projects: LocalProject[]
recentlyClosed: LocalProject[]
homedir: string
selected: HomeProjectSelection
focusServer: (server: ServerConnection.Any) => void
selectProject: (server: ServerConnection.Any, directory: string) => void
openNewSession: (server: ServerConnection.Any, directory: string) => void
openRecentProject: (server: ServerConnection.Any, directory: string) => void
chooseProject: (server: ServerConnection.Any) => void
editProject: (server: ServerConnection.Any, project: LocalProject) => void
closeProject: (server: ServerConnection.Any, directory: string) => void
clearNotifications: (server: ServerConnection.Any, project: LocalProject) => void
unseenCount: (server: ServerConnection.Any, project: LocalProject) => number
openSettings: () => void
openHelp: () => void
language: ReturnType<typeof useLanguage>
onWheel: (event: WheelEvent) => void
}) {
const global = useGlobal()
const dialog = useDialog()
const controller = useServerManagementController({ navigateOnAdd: false })
const [_state, setState, _, ready] = persisted(
Persist.global("home.servers", ["home.servers.v1"]),
createStore({ collapsed: {} as Record<string, boolean> }),
)
const [state] = createResource(
() => ready.promise ?? Promise.resolve(),
(p) => p.then(() => _state),
{ initialValue: _state },
)
return (
<aside
class="mt-6 flex min-h-0 min-w-0 flex-col gap-4 overflow-hidden lg:sticky lg:top-14 lg:mt-14 lg:h-[calc(100cqh-56px)] lg:self-start lg:pt-[52px]"
aria-label={props.language.t("home.projects")}
onWheel={(event) => {
if (event.target === event.currentTarget) return
props.onWheel(event)
}}
>
<div class="flex h-7 min-w-0 shrink-0 items-center justify-between pl-1.5 pr-3">
<div class="text-v2-text-text-muted [font-weight:530]">{props.language.t("home.projects")}</div>
<Show
when={global.servers.list().length === 1 && !(props.projects.length === 0 && props.recentlyClosed.length > 0)}
>
<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" />}
disabled={global.servers.health[ServerConnection.key(global.servers.list()[0]!)]?.healthy === false}
onClick={() => props.chooseProject(global.servers.list()[0]!)}
aria-label={props.language.t("home.project.add")}
/>
</TooltipV2>
</Show>
</div>
<ScrollView data-slot="home-projects-scroll" class="min-h-0 min-w-0 shrink">
<Show
when={global.servers.list().length > 1}
fallback={
<div class="pr-3">
<Show
when={props.projects.length > 0}
fallback={
<HomeProjectEmpty
server={global.servers.list()[0]!}
recentlyClosed={props.recentlyClosed}
homedir={props.homedir}
chooseProject={props.chooseProject}
openRecentProject={props.openRecentProject}
language={props.language}
/>
}
>
<HomeProjectList {...props} server={global.servers.list()[0]!} />
</Show>
</div>
}
>
<div class="flex min-w-0 flex-col gap-4 pr-3">
<For each={global.servers.list()}>
{(item) => {
const key = ServerConnection.key(item)
const healthy = () => !!global.servers.health[key]?.healthy
const serverCtx = global.ensureServerCtx(item)
const projects = () => serverCtx.projects.list()
const hasProjects = () => projects().length > 0
const collapsed = () => !!state().collapsed[key]
return (
<div class="flex min-w-0 flex-col gap-1">
<HomeServerRow
server={item}
selected={props.selected.server === key && !props.selected.directory}
collapsed={collapsed()}
health={global.servers.health[key]}
controller={controller}
focusServer={props.focusServer}
chooseProject={props.chooseProject}
openEdit={(server) => dialog.show(() => <DialogServerV2 mode="edit" server={server} />)}
toggleCollapsed={() => setState("collapsed", key, !state().collapsed[key])}
language={props.language}
/>
<Show when={healthy() && hasProjects() && !collapsed()}>
<div class="mx-3 h-px bg-v2-border-border-base" />
<HomeProjectList {...props} server={item} projects={projects()} />
</Show>
</div>
)
}}
</For>
</div>
</Show>
</ScrollView>
<HomeUtilityNav
class="mb-8 mt-4 hidden shrink-0 lg:flex"
openSettings={props.openSettings}
openHelp={props.openHelp}
language={props.language}
/>
</aside>
)
}
function HomeUtilityNav(props: {
class?: string
openSettings: () => void
openHelp: () => void
language: ReturnType<typeof useLanguage>
}) {
return (
<div class={`${props.class ?? ""} min-w-0 flex-col gap-1 pr-3`}>
<button
type="button"
class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
onClick={props.openSettings}
>
<IconV2 name="settings-gear" size="small" />
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("sidebar.settings")}</span>
</button>
<button
type="button"
class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
onClick={props.openHelp}
>
<IconV2 name="help" size="small" />
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("sidebar.help")}</span>
</button>
</div>
)
}
function HomeServerRow(props: {
server: ServerConnection.Any
selected: boolean
collapsed: boolean
health: ServerHealth | undefined
controller: ReturnType<typeof useServerManagementController>
focusServer: (server: ServerConnection.Any) => void
chooseProject: (server: ServerConnection.Any) => void
openEdit: (server: ServerConnection.Http) => void
toggleCollapsed: () => void
language: ReturnType<typeof useLanguage>
}) {
const global = useGlobal()
const [state, setState] = createStore({ menuOpen: false })
const healthy = () => !!props.health?.healthy
const canToggle = () => healthy() && global.ensureServerCtx(props.server).projects.list().length > 0
return (
<div class="group/server relative flex h-7 min-w-0 items-center rounded-[6px]">
<button
type="button"
class={`${HOME_PROJECT_NAV_ROW} pr-16 disabled:opacity-60`}
data-selected={props.selected ? "" : undefined}
disabled={!healthy()}
onClick={() => props.focusServer(props.server)}
>
<span
data-action="home-server-collapse"
class="inline-flex -ml-0.5 -mr-1.5 size-5 shrink-0 items-center justify-center rounded-[4px] text-v2-icon-icon-muted"
classList={{
"hover:bg-v2-overlay-simple-overlay-hover": canToggle(),
"cursor-default opacity-40": !canToggle(),
}}
aria-label={
props.collapsed ? props.language.t("home.server.expand") : props.language.t("home.server.collapse")
}
aria-disabled={!canToggle()}
aria-expanded={canToggle() ? !props.collapsed : undefined}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
if (!canToggle()) return
props.toggleCollapsed()
}}
onPointerDown={(event) => event.preventDefault()}
>
<IconV2
name="chevron-down"
size="small"
class="transition-transform duration-150 ease-in-out"
style={{ transform: `rotate(${props.collapsed ? -90 : 0}deg)` }}
/>
</span>
<div class="flex size-4 shrink-0 items-center justify-center -mr-0.5">
<ServerHealthIndicator health={props.health} />
</div>
<span class="flex min-w-0 items-center gap-1">
<span class={HOME_PROJECT_NAV_LABEL}>{props.server.displayName ?? new URL(props.server.http.url).host}</span>
<Show when={props.server.label}>
{(label) => (
<span class="shrink-0 rounded-[3px] border border-v2-border-border-base px-1 py-0.5 text-[9px] leading-none text-v2-text-text-muted">
{label()}
</span>
)}
</Show>
</span>
</button>
<div
class="hover-reveal absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-1 group-hover/server:opacity-100 focus-within:opacity-100 data-[menu=true]:opacity-100"
data-menu={state.menuOpen}
>
<ServerRowMenu
server={props.server}
controller={props.controller}
onEdit={props.openEdit}
open={state.menuOpen}
onOpenChange={(open) => setState("menuOpen", open)}
/>
<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")}
disabled={props.health?.healthy === false}
onClick={() => props.chooseProject(props.server)}
/>
</TooltipV2>
</div>
</div>
)
}
type HomeProjectListProps = {
server: ServerConnection.Any
projects: LocalProject[]
selected: HomeProjectSelection
selectProject: (server: ServerConnection.Any, directory: string) => void
openNewSession: (server: ServerConnection.Any, directory: string) => void
editProject: (server: ServerConnection.Any, project: LocalProject) => void
closeProject: (server: ServerConnection.Any, directory: string) => void
clearNotifications: (server: ServerConnection.Any, project: LocalProject) => void
unseenCount: (server: ServerConnection.Any, project: LocalProject) => number
language: ReturnType<typeof useLanguage>
}
function HomeProjectList(props: HomeProjectListProps) {
const global = useGlobal()
let listRef!: HTMLDivElement
const projects = () => global.ensureServerCtx(props.server).projects
return (
<DragDropProvider
sensors={(defaults) => [
...defaults.filter((sensor) => sensor !== PointerSensor),
PointerSensor.configure({
activationConstraints: (event) =>
event.pointerType === "touch"
? [new PointerActivationConstraints.Delay({ value: 250, tolerance: 5 })]
: [new PointerActivationConstraints.Distance({ value: 4 })],
preventActivation: (event) => event.target instanceof Element && !!event.target.closest("[data-action]"),
}),
]}
modifiers={[RestrictToVerticalAxis, RestrictToElement.configure({ element: () => listRef })]}
plugins={(defaults) => [
...defaults.filter((plugin) => plugin !== AutoScroller && plugin !== Feedback),
AutoScroller.configure({ acceleration: 8, threshold: { x: 0, y: 0.05 } }),
Feedback.configure({ dropAnimation: null }),
]}
onDragEnd={(event) => {
const source = event.operation.source
if (event.canceled || !isSortable(source)) return
if (source.initialIndex !== source.index) projects().move(source.id.toString(), source.index)
if (props.selected.server !== ServerConnection.key(props.server))
props.selectProject(props.server, source.id.toString())
}}
>
<div class="flex min-w-0 flex-col gap-1" ref={listRef}>
{/* Keyed on worktree strings: the enriched project objects are
recreated on every store or sync update, so iterating them directly
remounts all rows — killing any in-flight drag activation (the
row's sortable unregisters on unmount) and discarding animations.
String keys keep row elements alive and move them on reorder. */}
<For each={props.projects.map((project) => project.worktree)}>
{(worktree, index) => <HomeProjectSlot {...props} worktree={worktree} index={index} />}
</For>
</div>
</DragDropProvider>
)
}
function HomeProjectSlot(
props: HomeProjectListProps & {
worktree: string
index: () => number
},
) {
const project = createMemo(() => props.projects.find((item) => item.worktree === props.worktree))
return (
<Show when={project()}>
{(item) => (
<HomeProjectRow
project={item()}
server={props.server}
index={props.index}
serverSelected={props.selected.server === ServerConnection.key(props.server)}
selected={
props.selected.server === ServerConnection.key(props.server) && props.selected.directory === props.worktree
}
unseenCount={props.unseenCount(props.server, item())}
selectProject={props.selectProject}
openNewSession={props.openNewSession}
editProject={props.editProject}
closeProject={props.closeProject}
clearNotifications={props.clearNotifications}
language={props.language}
/>
)}
</Show>
)
}
function HomeProjectEmpty(props: {
server: ServerConnection.Any
recentlyClosed: LocalProject[]
homedir: string
chooseProject: (server: ServerConnection.Any) => void
openRecentProject: (server: ServerConnection.Any, directory: string) => void
language: ReturnType<typeof useLanguage>
}) {
const global = useGlobal()
const unreachable = () => global.servers.health[ServerConnection.key(props.server)]?.healthy === false
return (
<div class="flex min-w-0 flex-col gap-1">
<button
type="button"
data-action="home-add-project-row"
class={`${HOME_PROJECT_NAV_ROW} disabled:opacity-60 [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
disabled={unreachable()}
onClick={() => props.chooseProject(props.server)}
>
<IconV2 name="folder-add-left" size="small" />
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("home.project.add")}</span>
</button>
<Show when={props.recentlyClosed.length > 0}>
<div class="mt-3 flex h-7 min-w-0 shrink-0 items-center pl-1.5 pr-3">
<div class="text-v2-text-text-faint [font-weight:530]">{props.language.t("home.recentlyClosed")}</div>
</div>
<For each={props.recentlyClosed}>
{(project) => (
<HomeRecentlyClosedRow
project={project}
server={props.server}
homedir={props.homedir}
openRecentProject={props.openRecentProject}
language={props.language}
/>
)}
</For>
</Show>
</div>
)
}
function HomeRecentlyClosedRow(props: {
project: LocalProject
server: ServerConnection.Any
homedir: string
openRecentProject: (server: ServerConnection.Any, directory: string) => void
language: ReturnType<typeof useLanguage>
}) {
const global = useGlobal()
const unreachable = () => global.servers.health[ServerConnection.key(props.server)]?.healthy === false
const path = () => {
const home = props.homedir
const worktree = props.project.worktree
if (home && (worktree === home || worktree.startsWith(`${home}/`))) return `~${worktree.slice(home.length)}`
return worktree
}
return (
<TooltipV2 placement="right" value={path()}>
<button
type="button"
data-component="home-recently-closed-row"
class={`${HOME_PROJECT_NAV_ROW} disabled:opacity-60`}
disabled={unreachable()}
onClick={() => props.openRecentProject(props.server, props.project.worktree)}
>
<HomeProjectAvatar project={props.project} outline />
<span class={HOME_PROJECT_NAV_LABEL}>{displayName(props.project)}</span>
</button>
</TooltipV2>
)
}
function HomeProjectRow(props: {
project: LocalProject
server: ServerConnection.Any
index: () => number
serverSelected: boolean
selected: boolean
unseenCount: number
selectProject: (server: ServerConnection.Any, directory: string) => void
openNewSession: (server: ServerConnection.Any, directory: string) => void
editProject: (server: ServerConnection.Any, project: LocalProject) => void
closeProject: (server: ServerConnection.Any, directory: string) => void
clearNotifications: (server: ServerConnection.Any, project: LocalProject) => void
language: ReturnType<typeof useLanguage>
}) {
const global = useGlobal()
const platform = usePlatform()
const serverUnreachable = () => global.servers.health[ServerConnection.key(props.server)]?.healthy === false
const [state, setState] = createStore({ menuOpen: false })
const sortable = useSortable({
get id() {
return props.project.worktree
},
get index() {
return props.index()
},
})
let pointerDownSelected: boolean | undefined
const canRevealInFileManager = () =>
platform.platform === "desktop" && !!platform.openPath && ServerConnection.local(props.server)
const fileManagerActionLabel = () =>
props.language.t(
fileManagerApp(platform.platform === "desktop" ? (platform.os ?? "unknown") : "unknown").actionLabel,
)
const revealInFileManager = () => {
if (!platform.openPath) return
platform.openPath(props.project.worktree).catch((err: unknown) =>
showToast({
title: props.language.t("common.requestFailed"),
description: errorMessage(err, props.language.t("common.requestFailed")),
}),
)
}
return (
<div
ref={sortable.ref}
class="group/project relative flex h-7 min-w-0 items-center rounded-[6px]"
classList={{ "z-10": sortable.isDragSource() }}
>
<button
type="button"
data-component="home-project-row"
class={`${HOME_PROJECT_NAV_ROW} pr-16 disabled:opacity-60`}
classList={{
"bg-v2-background-bg-layer-01 text-v2-text-text-base [box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)]":
sortable.isDragSource(),
}}
data-selected={props.selected ? "" : undefined}
aria-current={props.selected ? "page" : undefined}
disabled={serverUnreachable()}
onPointerDown={(event) => {
// Same-server mouse selection happens on pointerdown (like tabs),
// but only ever selects; selectProject toggles, and deselecting here
// would fire on every drag before the threshold is met. Cross-server
// selection waits for click so reordering a remote server's projects
// does not focus that server and load its session index. Touch is
// excluded so flick-scrolling the list cannot select rows.
pointerDownSelected = undefined
if (event.button !== 0 || event.pointerType === "touch") return
if (!props.serverSelected) return
pointerDownSelected = props.selected
if (!props.selected) props.selectProject(props.server, props.project.worktree)
}}
onClick={(event) => {
// The drag sensor calls preventDefault on post-drag clicks; never
// toggle selection as part of a reorder.
if (event.defaultPrevented) return
// Keyboard activation and touch taps keep the original toggle.
if (event.detail === 0 || pointerDownSelected === undefined) {
props.selectProject(props.server, props.project.worktree)
return
}
// Mouse: pointerdown already selected unselected rows; a plain click
// on an already-selected row toggles it off.
if (pointerDownSelected) props.selectProject(props.server, props.project.worktree)
pointerDownSelected = undefined
}}
>
<HomeProjectAvatar project={props.project} />
<span class={HOME_PROJECT_NAV_LABEL}>{displayName(props.project)}</span>
</button>
<div
class="hover-reveal absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-1 group-hover/project:opacity-100 focus-within:opacity-100 data-[menu=true]:opacity-100"
data-menu={state.menuOpen}
>
<MenuV2
gutter={6}
modal={false}
placement="bottom-end"
open={state.menuOpen}
onOpenChange={(open) => setState("menuOpen", open)}
>
<MenuV2.Trigger
as={IconButtonV2}
data-action="home-project-menu"
variant="ghost-muted"
size="small"
icon={<IconV2 name="outline-dots" />}
aria-label={props.language.t("common.moreOptions")}
/>
<MenuV2.Portal>
<MenuV2.Content>
<MenuV2.Item onSelect={() => props.openNewSession(props.server, props.project.worktree)}>
{props.language.t("command.session.new")}
</MenuV2.Item>
<MenuV2.Item onSelect={() => props.editProject(props.server, props.project)}>
{props.language.t("dialog.project.edit.title")}
</MenuV2.Item>
<Show when={canRevealInFileManager()}>
<MenuV2.Item onSelect={revealInFileManager}>{fileManagerActionLabel()}</MenuV2.Item>
</Show>
<MenuV2.Item
disabled={props.unseenCount === 0}
onSelect={() => props.clearNotifications(props.server, props.project)}
>
{props.language.t("sidebar.project.clearNotifications")}
</MenuV2.Item>
<MenuV2.Separator />
<MenuV2.Item onSelect={() => props.closeProject(props.server, props.project.worktree)}>
{props.language.t("common.close")}
</MenuV2.Item>
</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>
)
}
function HomeProjectAvatar(props: { project: LocalProject; outline?: boolean }) {
const name = createMemo(() => displayName(props.project))
return (
<ProjectAvatar
fallback={name()}
src={props.outline ? undefined : getProjectAvatarSource(props.project.id, props.project.icon)}
variant={props.outline ? "outline" : getProjectAvatarVariant(props.project.icon?.color)}
/>
)
}
function HomeSessionLeading(props: {
project: LocalProject
session: Session
server: ServerConnection.Key
revealProjectOnHover: boolean
}) {
const tabs = useTabs()
const hasOpenTab = createMemo(() => sessionHasOpenTab(tabs.store, props.server, props.session))
return (
<div class="relative shrink-0">
<Show when={hasOpenTab()}>
<span
aria-hidden="true"
class="pointer-events-none absolute top-1/2 h-3 w-0.5 -translate-y-1/2 rounded-[2px] bg-v2-background-bg-layer-04"
style={{ right: "calc(100% + 4px)" }}
/>
</Show>
<SessionTabAvatar
project={props.project}
directory={props.session.directory}
sessionId={props.session.id}
server={props.server}
revealProjectOnHover={props.revealProjectOnHover}
/>
</div>
)
}
function HomeSessionSearch(props: {
value: string
placeholder: string
open: boolean
loading: boolean
results: HomeSessionRecord[]
showProjectName: boolean
server: ServerConnection.Key
noResultsLabel: string
bindFocus: (focus: () => void) => void
onInput: (value: string) => void
onFocus: () => void
onClose: () => void
onSelect: (session: Session, options?: OpenSessionOptions) => void
}) {
const language = useLanguage()
const [store, setStore] = createStore({ active: "" })
let root: HTMLDivElement | undefined
let input: HTMLInputElement | undefined
let listRef: HTMLDivElement | undefined
const focusInput = () => {
input?.focus()
props.onFocus()
}
onMount(() => {
props.bindFocus(focusInput)
})
const syncActive = (results: HomeSessionRecord[]) => {
if (results.length === 0) {
setStore("active", "")
return
}
if (!results.some((record) => homeSessionSearchKey(record) === store.active)) {
setStore("active", homeSessionSearchKey(results[0]))
}
}
createEffect(() => syncActive(props.results))
createEffect(
on(
() => props.value,
() => syncActive(props.results),
),
)
const scrollActiveIntoView = () => {
const key = store.active
if (!key || !listRef) return
const element = listRef.querySelector<HTMLElement>(`[data-key="${key}"]`)
element?.scrollIntoView({ block: "nearest" })
}
const moveActive = (delta: number) => {
const results = props.results
if (results.length === 0) return
const index = results.findIndex((record) => homeSessionSearchKey(record) === store.active)
const start = index === -1 ? 0 : index
const next = (start + delta + results.length) % results.length
setStore("active", homeSessionSearchKey(results[next]))
scrollActiveIntoView()
}
const selectActive = () => {
const record = props.results.find((item) => homeSessionSearchKey(item) === store.active)
if (!record) return
props.onSelect(record.session)
}
onCleanup(
makeEventListener(document, "pointerdown", (event) => {
if (!props.open) return
const target = event.target
if (!(target instanceof Node)) return
if (root?.contains(target)) return
props.onClose()
}),
)
return (
<div class="w-full">
<div ref={root} data-component="home-session-search" class="relative z-30 w-full">
<Show when={props.open}>
<div
data-component="home-session-search-panel"
class="absolute flex flex-col overflow-hidden rounded-[12px] bg-v2-background-bg-base shadow-[var(--v2-elevation-floating)]"
style={{
top: "-6px",
left: "-6px",
width: "calc(100% + 12px)",
}}
>
<div class="flex flex-col pt-9">
<div id={HOME_SESSION_SEARCH_RESULTS_ID} role="listbox" class="flex flex-col gap-4 pt-4">
<Show
when={!props.loading}
fallback={
<div class="flex items-center justify-center px-4 py-3 text-v2-text-text-muted [font-weight:440]">
<Spinner class="size-4" />
</div>
}
>
<Show
when={props.results.length > 0}
fallback={
<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 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>
<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}
selected={store.active === homeSessionSearchKey(record)}
onHighlight={() => setStore("active", homeSessionSearchKey(record))}
onSelect={(session, options) => props.onSelect(session, options)}
/>
)}
</For>
</div>
</ScrollView>
</div>
</Show>
</Show>
</div>
</div>
</div>
</Show>
<label class="relative z-20 flex h-9 w-full items-center gap-2 rounded-[6px] bg-v2-background-bg-layer-02/60 py-1 pl-3 pr-2 text-v2-icon-icon-muted transition-[background-color,box-shadow] duration-[120ms] ease-in-out hover:bg-v2-background-bg-layer-02 focus-within:bg-v2-background-bg-layer-02">
<IconV2 name="magnifying-glass" />
<input
ref={input}
class="relative z-20 min-w-0 flex-1 border-0 bg-transparent text-v2-text-text-base outline-0 [font-weight:440] placeholder:text-v2-text-text-faint"
value={props.value}
placeholder={props.placeholder}
aria-label={props.placeholder}
aria-expanded={props.open}
aria-controls={HOME_SESSION_SEARCH_RESULTS_ID}
aria-autocomplete="list"
aria-activedescendant={
store.active && props.open ? `home-session-search-option-${store.active}` : undefined
}
onFocus={() => props.onFocus()}
onInput={(event) => props.onInput(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault()
props.onClose()
input?.blur()
return
}
if (!props.open || props.results.length === 0) return
if (event.altKey || event.metaKey) return
if (event.key === "ArrowDown") {
event.preventDefault()
moveActive(1)
return
}
if (event.key === "ArrowUp") {
event.preventDefault()
moveActive(-1)
return
}
if (event.key === "Enter" && !event.isComposing) {
event.preventDefault()
selectActive()
}
}}
/>
<Show when={props.value}>
<IconButtonV2
type="button"
variant="ghost-muted"
size="small"
class="relative z-20 shrink-0"
icon={<IconV2 name="close" size="large" class="text-v2-icon-icon-muted" />}
aria-label={props.placeholder}
onClick={() => {
props.onClose()
input?.focus()
}}
/>
</Show>
</label>
</div>
</div>
)
}
function HomeSessionSearchResultRow(props: {
record: HomeSessionRecord
showProjectName: boolean
server: ServerConnection.Key
selected: boolean
onHighlight: () => void
onSelect: (session: Session, options?: OpenSessionOptions) => 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)
return (
<button
type="button"
id={`home-session-search-option-${key()}`}
data-key={key()}
data-component="home-session-search-row"
role="option"
aria-selected={props.selected}
classList={{
[HOME_SEARCH_RESULT_ROW]: true,
"bg-v2-overlay-simple-overlay-hover": props.selected,
group: !!showProjectName(),
}}
onMouseEnter={() => props.onHighlight()}
onMouseDown={(event) => {
if (event.button === 1) event.preventDefault()
}}
onClick={(event) => props.onSelect(props.record.session, { background: isBackgroundOpen(event) })}
onAuxClick={(event) => {
if (!isBackgroundOpen(event)) return
event.preventDefault()
props.onSelect(props.record.session, { background: true })
}}
>
<HomeSessionLeading
project={props.record.project}
session={props.record.session}
server={props.server}
revealProjectOnHover={!!showProjectName()}
/>
<div class="flex min-w-0 flex-1 items-center gap-1.5">
<span
class={`${HOME_SEARCH_RESULT_TITLE} ${showProjectName() ? "max-w-[min(70%,480px)] flex-[0_1_auto]" : "flex-[1_1_auto]"}`}
>
{title()}
</span>
<Show when={showProjectName()}>
<span class={HOME_SEARCH_RESULT_META}>{props.record.projectName}</span>
</Show>
</div>
</button>
)
}
function HomeSessionGroupHeader(props: {
title: string
titleOpacity: number
ref: ComponentProps<"div">["ref"]
elevated?: boolean
}) {
return (
<div
ref={props.ref}
class={`pointer-events-none sticky top-[84px] lg:top-[108px] flex h-7 min-w-0 items-center justify-between pl-3 bg-v2-background-bg-base ${props.elevated ? "home-session-group-header z-[5]" : "z-10"}`}
>
<div class={HOME_SECTION_LABEL} style={{ opacity: props.titleOpacity }}>
{props.title}
</div>
</div>
)
}
function HomeSessionRow(props: {
record: HomeSessionRecord
showProjectName: boolean
server: ServerConnection.Key
openSession: (session: Session, options?: OpenSessionOptions) => 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 (
<div
class="group/session relative flex h-10 min-w-0 items-center rounded-[6px]"
classList={{ group: !!showProjectName() }}
>
<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`}
onMouseDown={(event) => {
if (event.button === 1) event.preventDefault()
}}
onClick={(event) => props.openSession(props.record.session, { background: isBackgroundOpen(event) })}
onAuxClick={(event) => {
if (!isBackgroundOpen(event)) return
event.preventDefault()
props.openSession(props.record.session, { background: true })
}}
>
<HomeSessionLeading
project={props.record.project}
session={props.record.session}
server={props.server}
revealProjectOnHover={!!showProjectName()}
/>
<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 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>
<Show when={SHOW_HOME_SESSION_ARCHIVE}>
<div class="hover-reveal absolute right-1.5 top-1/2 flex -translate-y-1/2 items-center gap-1 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>
</Show>
</div>
)
}
function HomeSessionsEmpty(props: { onNewSession?: () => void }) {
const language = useLanguage()
return (
<div class="flex min-h-full flex-col items-center gap-4 px-6 pt-[52px] text-center">
<div class="shrink-0 text-[13px] leading-[13px] tracking-[-0.04px] text-v2-text-text-base [font-weight:530]">
{language.t("home.sessions.empty")}
</div>
<p class="mb-1 text-center text-[13px] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-weight:440]">
{language.t("home.sessions.empty.description")}
</p>
<Show when={props.onNewSession}>
{(onNewSession) => (
<ButtonV2 data-action="home-new-session" variant="neutral" size="normal" icon="edit" onClick={onNewSession()}>
{language.t("command.session.new")}
</ButtonV2>
)}
</Show>
</div>
)
}
function HomeSessionSkeleton(props: { label: string }) {
return (
<div class="flex min-w-0 flex-col gap-4">
<div class="flex h-7 min-w-0 items-center justify-between px-4">
<div class={HOME_SECTION_LABEL}>{props.label}</div>
</div>
<div class="flex min-w-0 flex-col gap-px" aria-hidden="true">
<For each={[0, 1, 2, 3]}>{() => <div class="h-10 rounded-[6px] bg-v2-background-bg-deep opacity-70" />}</For>
</div>
</div>
)
}
function groupSessions(records: HomeSessionRecord[], language: ReturnType<typeof useLanguage>): HomeSessionGroup[] {
const now = DateTime.local()
const yesterday = now.minus({ days: 1 })
const todaySessions = records.filter((record) =>
DateTime.fromMillis(record.session.time.updated ?? record.session.time.created).hasSame(now, "day"),
)
const yesterdaySessions = records.filter((record) =>
DateTime.fromMillis(record.session.time.updated ?? record.session.time.created).hasSame(yesterday, "day"),
)
const olderSessions = records.filter((record) => {
const time = DateTime.fromMillis(record.session.time.updated ?? record.session.time.created)
return !time.hasSame(now, "day") && !time.hasSame(yesterday, "day")
})
const olderTitle =
todaySessions.length === 0 && yesterdaySessions.length === 0
? language.t("sidebar.project.recentSessions")
: language.t("home.sessions.group.older")
return [
{ id: "today" as const, title: language.t("home.sessions.group.today"), sessions: todaySessions },
{ id: "yesterday" as const, title: language.t("home.sessions.group.yesterday"), sessions: yesterdaySessions },
{ id: "older" as const, title: olderTitle, sessions: olderSessions },
].filter((group) => group.sessions.length > 0)
}
export function LegacyHome() {
const sync = useServerSync()
const platform = usePlatform()
const pickDirectory = useDirectoryPicker()
const dialog = useDialog()
const navigate = useNavigate()
const global = useGlobal()
const server = useServer()
const language = useLanguage()
const homedir = createMemo(() => sync().data.path.home)
const serverUnreachable = createMemo(() => global.servers.health[server.key]?.healthy === false)
const recent = createMemo(() => {
return sync()
.data.project.slice()
.sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
.slice(0, 5)
})
const serverDotClass = createMemo(() => {
const healthy = global.servers.health[server.key]?.healthy
if (healthy === true) return "bg-icon-success-base"
if (healthy === false) return "bg-icon-critical-base"
return "bg-border-weak-base"
})
function openProject(server: ServerConnection.Any, directory: string) {
const serverCtx = global.ensureServerCtx(server)
serverCtx.projects.open(directory)
serverCtx.projects.touch(directory)
navigate(`/${base64Encode(directory)}`)
}
function chooseProject() {
if (serverUnreachable()) return
const s = server.current
if (!s) return
const resolve = (result: string | string[] | null) => {
if (Array.isArray(result)) {
for (const directory of result) {
openProject(s, directory)
}
} else if (result) {
openProject(s, result)
}
}
pickDirectory({
server: s,
title: language.t("command.project.open"),
multiple: true,
onSelect: resolve,
})
}
return (
<div class="mx-auto mt-55 w-full md:w-auto px-4">
<Logo class="md:w-xl opacity-12" />
<Button
size="large"
variant="ghost"
class="mt-4 mx-auto text-14-regular text-text-weak"
onClick={() => dialog.show(() => <DialogSelectServer />)}
>
<div
classList={{
"size-2 rounded-full": true,
[serverDotClass()]: true,
}}
/>
{server.name}
</Button>
<Switch>
<Match when={sync().data.project.length > 0}>
<div class="mt-20 w-full flex flex-col gap-4">
<div class="flex gap-2 items-center justify-between pl-3">
<div class="text-14-medium text-text-strong">{language.t("home.recentProjects")}</div>
<Button
icon="folder-add-left"
size="normal"
class="pl-2 pr-3"
disabled={serverUnreachable()}
onClick={chooseProject}
>
{language.t("command.project.open")}
</Button>
</div>
<ul class="flex flex-col gap-2">
<For each={recent()}>
{(project) => (
<Button
size="large"
variant="ghost"
class="text-14-mono text-left justify-between px-3"
onClick={() => openProject(server.current!, project.worktree)}
>
{project.worktree.replace(homedir(), "~")}
<div class="text-14-regular text-text-weak">
{DateTime.fromMillis(project.time.updated ?? project.time.created).toRelative()}
</div>
</Button>
)}
</For>
</ul>
</div>
</Match>
<Match when={!sync().ready}>
<div class="mt-30 mx-auto flex flex-col items-center gap-3">
<div class="text-12-regular text-text-weak">{language.t("common.loading")}</div>
<Button class="px-3" disabled={serverUnreachable()} onClick={chooseProject}>
{language.t("command.project.open")}
</Button>
</div>
</Match>
<Match when={true}>
<div class="mt-30 mx-auto flex flex-col items-center gap-3">
<Icon name="folder-add-left" size="large" />
<div class="flex flex-col gap-1 items-center justify-center">
<div class="text-14-medium text-text-strong">{language.t("home.empty.title")}</div>
<div class="text-12-regular text-text-weak">{language.t("home.empty.description")}</div>
</div>
<Button class="px-3 mt-1" disabled={serverUnreachable()} onClick={chooseProject}>
{language.t("command.project.open")}
</Button>
</div>
</Match>
</Switch>
</div>
)
}
@@ -0,0 +1,265 @@
import { useDirectoryPicker } from "@/components/directory-picker"
import { useServerManagementController } from "@/components/dialog-select-server"
import { useSettingsCommand } from "@/components/settings-dialog"
import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2"
import { useGlobal } from "@/context/global"
import { type HomeProjectSelection, type LocalProject, useLayout } from "@/context/layout"
import { useLanguage } from "@/context/language"
import { useNotification } from "@/context/notification"
import { usePlatform } from "@/context/platform"
import { ServerConnection, useServer } from "@/context/server"
import { useServerSync } from "@/context/server-sync"
import { useTabs } from "@/context/tabs"
import {
closeHomeProject,
errorMessage,
homeProjectDirectories,
toggleHomeProjectSelection,
} from "@/pages/layout/helpers"
import { fileManagerApp } from "@/utils/file-manager"
import { Persist, persisted } from "@/utils/persist"
import { showToast } from "@/utils/toast"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createEffect, createMemo, createResource } from "solid-js"
import { createStore } from "solid-js/store"
export function createHomeProjectsController() {
const sync = useServerSync()
const layout = useLayout()
const platform = usePlatform()
const pickDirectory = useDirectoryPicker()
const dialog = useDialog()
const server = useServer()
const language = useLanguage()
const global = useGlobal()
const tabs = useTabs()
const notification = useNotification()
const openSettings = useSettingsCommand()
const serverManagement = useServerManagementController({ navigateOnAdd: false })
const [menu, setMenu] = createStore({ open: undefined as string | undefined })
const [_state, setState, _, ready] = persisted(
Persist.global("home.servers", ["home.servers.v1"]),
createStore({ collapsed: {} as Record<string, boolean> }),
)
const [state] = createResource(
() => ready.promise ?? Promise.resolve(),
(promise) => promise.then(() => _state),
{ initialValue: _state },
)
const selection = layout.home.selection
const focusedServer = createMemo(
() => global.servers.list().find((conn) => ServerConnection.key(conn) === selection().server) ?? server.current,
)
const focusedServerCtx = createMemo(() => {
const conn = focusedServer()
if (!conn) return undefined
return global.ensureServerCtx(conn)
})
const focusedSync = () => focusedServerCtx()?.sync ?? sync()
const projects = createMemo(() => focusedServerCtx()?.projects.list() ?? layout.projects.list())
const recentlyClosed = createMemo(
() => focusedServerCtx()?.projects.recentlyClosed() ?? layout.projects.recentlyClosed(),
)
const homedir = createMemo(() => focusedSync().data.path.home ?? "")
const selectedProject = createMemo(() => projects().find((project) => project.worktree === selection().directory))
const newSessionProject = createMemo(
() =>
selectedProject() ??
projects().find((project) => project.worktree === focusedServerCtx()?.projects.last()) ??
projects()[0],
)
createEffect(() => {
const id = menu.open
if (!id) return
const connections = global.servers.list()
const valid = connections.some((conn) => {
if (serverMenuID(conn) === id) return true
if (
connections.length > 1 &&
(global.servers.health[ServerConnection.key(conn)]?.healthy !== true || collapsed(conn))
)
return false
const list = connections.length === 1 ? projects() : projectsForServer(conn)
return list.some((project) => projectMenuID(conn, project.worktree) === id)
})
if (!valid) setMenu("open", undefined)
})
createEffect(() => {
const list = global.servers.list()
if (list.some((conn) => ServerConnection.key(conn) === selection().server)) return
const conn = list.find((conn) => ServerConnection.key(conn) === server.key) ?? list[0]
if (conn) setSelection({ server: ServerConnection.key(conn) })
})
function setSelection(next: HomeProjectSelection) {
layout.home.setSelection(next)
}
function focusServer(conn: ServerConnection.Any) {
setSelection({ server: ServerConnection.key(conn) })
}
function selectProject(conn: ServerConnection.Any, directory: string) {
const key = ServerConnection.key(conn)
if (global.servers.health[key]?.healthy === false) return
if (
!global
.ensureServerCtx(conn)
.projects.list()
.some((project) => project.worktree === directory)
)
return
setSelection(toggleHomeProjectSelection(selection(), key, directory))
}
function addProjects(conn: ServerConnection.Any, directories: string[]) {
const directory = directories[0]
if (!directory) return
const ctx = global.ensureServerCtx(conn)
directories.forEach((item) => ctx.projects.open(item))
ctx.projects.touch(directory)
setSelection({ server: ServerConnection.key(conn), directory })
}
function openNewSession() {
const conn = focusedServer()
const project = newSessionProject()
if (!conn || !project) return
openProjectNewSession(conn, project.worktree)
}
function openProjectNewSession(conn: ServerConnection.Any, directory: string) {
const ctx = global.ensureServerCtx(conn)
ctx.projects.open(directory)
ctx.projects.touch(directory)
void tabs.newDraft({ server: ServerConnection.key(conn), directory })
}
function editProject(conn: ServerConnection.Any, project: LocalProject) {
void import("@/components/dialog-edit-project-v2").then(({ DialogEditProjectV2 }) => {
void dialog.show(() => <DialogEditProjectV2 server={conn} project={project} />)
})
}
function directories(project: LocalProject) {
return [project.worktree, ...(project.sandboxes ?? [])]
}
function unseenCount(conn: ServerConnection.Any, project: LocalProject) {
const state = notification.ensureServerState(ServerConnection.key(conn))
return directories(project).reduce((total, directory) => total + state.project.unseenCount(directory), 0)
}
function clearNotifications(conn: ServerConnection.Any, project: LocalProject) {
const state = notification.ensureServerState(ServerConnection.key(conn))
directories(project)
.filter((directory) => state.project.unseenCount(directory) > 0)
.forEach((directory) => state.project.markViewed(directory))
}
function chooseProject(conn: ServerConnection.Any) {
if (global.servers.health[ServerConnection.key(conn)]?.healthy === false) return
pickDirectory({
server: conn,
title: language.t("command.project.open"),
multiple: true,
onSelect: (result) => addProjects(conn, homeProjectDirectories(result)),
})
}
function closeProject(conn: ServerConnection.Any, directory: string) {
const next = closeHomeProject(
selection(),
ServerConnection.key(conn),
global.ensureServerCtx(conn).projects,
directory,
)
if (next) setSelection(next)
}
function moveProject(conn: ServerConnection.Any, worktree: string, index: number) {
global.ensureServerCtx(conn).projects.move(worktree, index)
}
function revealProject(conn: ServerConnection.Any, project: LocalProject) {
if (!platform.openPath || !canRevealProject(conn)) return
platform.openPath(project.worktree).catch((cause: unknown) =>
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(cause, language.t("common.requestFailed")),
}),
)
}
function canRevealProject(conn: ServerConnection.Any) {
return platform.platform === "desktop" && !!platform.openPath && ServerConnection.local(conn)
}
function projectsForServer(conn: ServerConnection.Any) {
return global.ensureServerCtx(conn).projects.list()
}
function collapsed(conn: ServerConnection.Any) {
return state().collapsed[ServerConnection.key(conn)] ?? false
}
function serverMenuID(conn: ServerConnection.Any) {
return `server:${ServerConnection.key(conn)}`
}
function projectMenuID(conn: ServerConnection.Any, directory: string) {
return `project:${ServerConnection.key(conn)}:${directory}`
}
return {
language,
selection,
focusedServer,
focusedServerCtx,
focusedSync,
projects,
recentlyClosed,
homedir,
selectedProject,
newSessionProject,
servers: global.servers.list,
serverHealth: (conn: ServerConnection.Any) => global.servers.health[ServerConnection.key(conn)],
projectsForServer,
collapsed,
toggleCollapsed: (conn: ServerConnection.Any) => {
const key = ServerConnection.key(conn)
setState("collapsed", key, !state().collapsed[key])
},
menuOpen: (id: string) => menu.open === id,
setMenuOpen: (id: string, open: boolean) => setMenu("open", open ? id : undefined),
serverMenuID,
projectMenuID,
canDefaultServer: serverManagement.canDefault,
isDefaultServer: (conn: ServerConnection.Any) => serverManagement.defaultKey() === ServerConnection.key(conn),
setDefaultServer: (conn: ServerConnection.Any | undefined) =>
serverManagement.setDefault(conn ? ServerConnection.key(conn) : null),
removeServer: (conn: ServerConnection.Any) => serverManagement.handleRemove(ServerConnection.key(conn)),
openEditServer: (conn: ServerConnection.Http) => dialog.show(() => <DialogServerV2 mode="edit" server={conn} />),
focusServer,
selectProject,
addProjects,
openNewSession,
openProjectNewSession,
editProject,
unseenCount,
clearNotifications,
chooseProject,
closeProject,
moveProject,
canRevealProject,
revealProject,
fileManagerActionLabel: () =>
language.t(fileManagerApp(platform.platform === "desktop" ? (platform.os ?? "unknown") : "unknown").actionLabel),
openSettings,
openHelp: () => platform.openLink("https://opencode.ai/desktop-feedback"),
}
}
export type HomeProjectsController = ReturnType<typeof createHomeProjectsController>
@@ -0,0 +1,548 @@
import { type Accessor, createMemo, For, Show } from "solid-js"
import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
import { isSortable, useSortable } from "@dnd-kit/solid/sortable"
import { AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/dom"
import { RestrictToVerticalAxis } from "@dnd-kit/abstract/modifiers"
import { RestrictToElement } from "@dnd-kit/dom/modifiers"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-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, type HomeProjectSelection, type LocalProject } from "@/context/layout"
import { ServerConnection } from "@/context/server"
import { useLanguage } from "@/context/language"
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
import { ServerRowMenuView, serverMenuLabels } from "@/components/server/server-row-menu"
import { ServerHealthIndicator } from "@/components/server/server-row"
import { type ServerHealth } from "@/utils/server-health"
const HOME_ROW_LAYOUT =
"flex min-w-0 w-full shrink-0 cursor-default items-center rounded-[6px] bg-transparent text-left transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out focus-visible:outline-none"
const HOME_PROJECT_NAV_LABEL = "min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap"
const HOME_PROJECT_NAV_ROW = `${HOME_ROW_LAYOUT} h-7 gap-2 px-1.5 [font-weight:440] text-v2-text-text-muted hover:bg-v2-background-bg-layer-01 hover:text-v2-text-text-base hover:[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)] data-[selected]:bg-v2-background-bg-layer-03 data-[selected]:text-v2-text-text-base data-[selected]:[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)] data-[selected]:hover:bg-v2-background-bg-layer-03 focus-visible:bg-v2-background-bg-layer-01 focus-visible:text-v2-text-text-base focus-visible:[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)]`
export type HomeProjectsViewProps = {
language: ReturnType<typeof useLanguage>
servers: Accessor<ServerConnection.Any[]>
projects: Accessor<LocalProject[]>
recentlyClosed: Accessor<LocalProject[]>
selection: Accessor<HomeProjectSelection>
homedir: Accessor<string>
serverHealth: (server: ServerConnection.Any) => ServerHealth | undefined
projectsForServer: (server: ServerConnection.Any) => LocalProject[]
collapsed: (server: ServerConnection.Any) => boolean
menuOpen: (id: string) => boolean
canDefaultServer: Accessor<boolean>
isDefaultServer: (server: ServerConnection.Any) => boolean
canRevealProject: (server: ServerConnection.Any) => boolean
fileManagerActionLabel: Accessor<string>
unseenCount: (server: ServerConnection.Any, project: LocalProject) => number
serverMenuID: (server: ServerConnection.Any) => string
projectMenuID: (server: ServerConnection.Any, directory: string) => string
onWheel: (event: WheelEvent) => void
onChooseProject: (server: ServerConnection.Any) => void
onFocusServer: (server: ServerConnection.Any) => void
onToggleCollapsed: (server: ServerConnection.Any) => void
onEditServer: (server: ServerConnection.Http) => void
onSetDefaultServer: (server: ServerConnection.Any | undefined) => void
onRemoveServer: (server: ServerConnection.Any) => void
onSetMenuOpen: (id: string, open: boolean) => void
onMoveProject: (server: ServerConnection.Any, worktree: string, index: number) => void
onSelectProject: (server: ServerConnection.Any, directory: string) => void
onAddProjects: (server: ServerConnection.Any, directories: string[]) => void
onOpenProjectNewSession: (server: ServerConnection.Any, directory: string) => void
onEditProject: (server: ServerConnection.Any, project: LocalProject) => void
onRevealProject: (server: ServerConnection.Any, project: LocalProject) => void
onClearNotifications: (server: ServerConnection.Any, project: LocalProject) => void
onCloseProject: (server: ServerConnection.Any, directory: string) => void
onOpenSettings: () => void
onOpenHelp: () => void
}
export function HomeProjectsView(props: HomeProjectsViewProps) {
return (
<aside
class="mt-6 flex min-h-0 min-w-0 flex-col gap-4 overflow-hidden lg:sticky lg:top-14 lg:mt-14 lg:h-[calc(100cqh-56px)] lg:self-start lg:pt-[52px]"
aria-label={props.language.t("home.projects")}
onWheel={(event) => {
if (event.target === event.currentTarget) return
props.onWheel(event)
}}
>
<div class="flex h-7 min-w-0 shrink-0 items-center justify-between pl-1.5 pr-3">
<div class="text-v2-text-text-muted [font-weight:530]">{props.language.t("home.projects")}</div>
<Show
when={props.servers().length === 1 && !(props.projects().length === 0 && props.recentlyClosed().length > 0)}
>
<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" />}
disabled={props.serverHealth(props.servers()[0])?.healthy === false}
onClick={() => props.onChooseProject(props.servers()[0])}
aria-label={props.language.t("home.project.add")}
/>
</TooltipV2>
</Show>
</div>
<ScrollView data-slot="home-projects-scroll" class="min-h-0 min-w-0 shrink">
<Show
when={props.servers().length > 1}
fallback={
<div class="pr-3">
<Show
when={props.projects().length > 0}
fallback={<HomeProjectEmpty {...props} server={props.servers()[0]} items={props.recentlyClosed()} />}
>
<HomeProjectList {...props} server={props.servers()[0]} items={props.projects()} />
</Show>
</div>
}
>
<div class="flex min-w-0 flex-col gap-4 pr-3">
<For each={props.servers()}>
{(item) => {
const projects = () => props.projectsForServer(item)
const healthy = () => !!props.serverHealth(item)?.healthy
const hasProjects = () => projects().length > 0
const collapsed = () => props.collapsed(item)
return (
<div class="flex min-w-0 flex-col gap-1">
<HomeServerRow
server={item}
{...props}
selected={props.selection().server === ServerConnection.key(item) && !props.selection().directory}
collapsed={collapsed()}
health={props.serverHealth(item)}
/>
<Show when={healthy() && hasProjects() && !collapsed()}>
<div class="mx-3 h-px bg-v2-border-border-base" />
<HomeProjectList {...props} server={item} items={projects()} />
</Show>
</div>
)
}}
</For>
</div>
</Show>
</ScrollView>
<HomeUtilityNav
class="mb-8 mt-4 hidden shrink-0 lg:flex"
onOpenSettings={props.onOpenSettings}
onOpenHelp={props.onOpenHelp}
language={props.language}
/>
</aside>
)
}
export function HomeUtilityNav(props: {
class?: string
onOpenSettings: () => void
onOpenHelp: () => void
language: ReturnType<typeof useLanguage>
}) {
return (
<div class={`${props.class ?? ""} min-w-0 flex-col gap-1 pr-3`}>
<button
type="button"
class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
onClick={props.onOpenSettings}
>
<IconV2 name="settings-gear" size="small" />
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("sidebar.settings")}</span>
</button>
<button
type="button"
class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
onClick={props.onOpenHelp}
>
<IconV2 name="help" size="small" />
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("sidebar.help")}</span>
</button>
</div>
)
}
function HomeServerRow(props: {
language: HomeProjectsViewProps["language"]
projectsForServer: HomeProjectsViewProps["projectsForServer"]
menuOpen: HomeProjectsViewProps["menuOpen"]
canDefaultServer: HomeProjectsViewProps["canDefaultServer"]
isDefaultServer: HomeProjectsViewProps["isDefaultServer"]
serverMenuID: HomeProjectsViewProps["serverMenuID"]
onFocusServer: HomeProjectsViewProps["onFocusServer"]
onToggleCollapsed: HomeProjectsViewProps["onToggleCollapsed"]
onEditServer: HomeProjectsViewProps["onEditServer"]
onSetDefaultServer: HomeProjectsViewProps["onSetDefaultServer"]
onRemoveServer: HomeProjectsViewProps["onRemoveServer"]
onSetMenuOpen: HomeProjectsViewProps["onSetMenuOpen"]
onChooseProject: HomeProjectsViewProps["onChooseProject"]
server: ServerConnection.Any
selected: boolean
collapsed: boolean
health: ServerHealth | undefined
}) {
const healthy = () => !!props.health?.healthy
const canToggle = () => healthy() && props.projectsForServer(props.server).length > 0
const menuID = () => props.serverMenuID(props.server)
return (
<div class="group/server relative flex h-7 min-w-0 items-center rounded-[6px]">
<button
type="button"
class={`${HOME_PROJECT_NAV_ROW} pr-16 disabled:opacity-60`}
data-selected={props.selected ? "" : undefined}
disabled={!healthy()}
onClick={() => props.onFocusServer(props.server)}
>
<span
data-action="home-server-collapse"
class="inline-flex -ml-0.5 -mr-1.5 size-5 shrink-0 items-center justify-center rounded-[4px] text-v2-icon-icon-muted"
classList={{
"hover:bg-v2-overlay-simple-overlay-hover": canToggle(),
"cursor-default opacity-40": !canToggle(),
}}
aria-label={
props.collapsed ? props.language.t("home.server.expand") : props.language.t("home.server.collapse")
}
aria-disabled={!canToggle()}
aria-expanded={canToggle() ? !props.collapsed : undefined}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
if (!canToggle()) return
props.onToggleCollapsed(props.server)
}}
onPointerDown={(event) => event.preventDefault()}
>
<IconV2
name="chevron-down"
size="small"
class="transition-transform duration-150 ease-in-out"
style={{ transform: `rotate(${props.collapsed ? -90 : 0}deg)` }}
/>
</span>
<div class="flex size-4 shrink-0 items-center justify-center -mr-0.5">
<ServerHealthIndicator health={props.health} />
</div>
<span class="flex min-w-0 items-center gap-1">
<span class={HOME_PROJECT_NAV_LABEL}>{props.server.displayName ?? new URL(props.server.http.url).host}</span>
<Show when={props.server.label}>
{(label) => (
<span class="shrink-0 rounded-[3px] border border-v2-border-border-base px-1 py-0.5 text-[9px] leading-none text-v2-text-text-muted">
{label()}
</span>
)}
</Show>
</span>
</button>
<div
class="hover-reveal absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-1 group-hover/server:opacity-100 focus-within:opacity-100 data-[menu=true]:opacity-100"
data-menu={props.menuOpen(menuID())}
>
<ServerRowMenuView
server={props.server}
labels={serverMenuLabels(props.language)}
canDefault={props.canDefaultServer()}
isDefault={props.isDefaultServer(props.server)}
onEdit={props.onEditServer}
onSetDefault={() => props.onSetDefaultServer(props.server)}
onRemoveDefault={() => props.onSetDefaultServer(undefined)}
onRemove={() => props.onRemoveServer(props.server)}
open={props.menuOpen(menuID())}
onOpenChange={(open) => props.onSetMenuOpen(menuID(), open)}
/>
<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")}
disabled={props.health?.healthy === false}
onClick={() => props.onChooseProject(props.server)}
/>
</TooltipV2>
</div>
</div>
)
}
type HomeProjectListProps = HomeProjectsViewProps & {
server: ServerConnection.Any
items: LocalProject[]
}
function HomeProjectList(props: HomeProjectListProps) {
let listRef!: HTMLDivElement
return (
<DragDropProvider
sensors={(defaults) => [
...defaults.filter((sensor) => sensor !== PointerSensor),
PointerSensor.configure({
activationConstraints: (event) =>
event.pointerType === "touch"
? [new PointerActivationConstraints.Delay({ value: 250, tolerance: 5 })]
: [new PointerActivationConstraints.Distance({ value: 4 })],
preventActivation: (event) => event.target instanceof Element && !!event.target.closest("[data-action]"),
}),
]}
modifiers={[RestrictToVerticalAxis, RestrictToElement.configure({ element: () => listRef })]}
plugins={(defaults) => [
...defaults.filter((plugin) => plugin !== AutoScroller && plugin !== Feedback),
AutoScroller.configure({ acceleration: 8, threshold: { x: 0, y: 0.05 } }),
Feedback.configure({ dropAnimation: null }),
]}
onDragEnd={(event) => {
const source = event.operation.source
if (event.canceled || !isSortable(source)) return
if (source.initialIndex !== source.index) props.onMoveProject(props.server, source.id.toString(), source.index)
if (props.selection().server !== ServerConnection.key(props.server))
props.onSelectProject(props.server, source.id.toString())
}}
>
<div class="flex min-w-0 flex-col gap-1" ref={listRef}>
{/* Keyed on worktree strings: the enriched project objects are
recreated on every store or sync update, so iterating them directly
remounts all rows — killing any in-flight drag activation (the
row's sortable unregisters on unmount) and discarding animations.
String keys keep row elements alive and move them on reorder. */}
<For each={props.items.map((project) => project.worktree)}>
{(worktree, index) => <HomeProjectSlot {...props} worktree={worktree} index={index} />}
</For>
</div>
</DragDropProvider>
)
}
function HomeProjectSlot(
props: HomeProjectListProps & {
worktree: string
index: () => number
},
) {
const project = createMemo(() => props.items.find((item) => item.worktree === props.worktree))
return (
<Show when={project()}>
{(item) => (
<HomeProjectRow
{...props}
project={item()}
server={props.server}
index={props.index}
serverSelected={props.selection().server === ServerConnection.key(props.server)}
selected={
props.selection().server === ServerConnection.key(props.server) &&
props.selection().directory === props.worktree
}
unseen={props.unseenCount(props.server, item())}
/>
)}
</Show>
)
}
function HomeProjectEmpty(
props: HomeProjectsViewProps & {
server: ServerConnection.Any
items: LocalProject[]
},
) {
const unreachable = () => props.serverHealth(props.server)?.healthy === false
return (
<div class="flex min-w-0 flex-col gap-1">
<button
type="button"
data-action="home-add-project-row"
class={`${HOME_PROJECT_NAV_ROW} disabled:opacity-60 [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
disabled={unreachable()}
onClick={() => props.onChooseProject(props.server)}
>
<IconV2 name="folder-add-left" size="small" />
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("home.project.add")}</span>
</button>
<Show when={props.items.length > 0}>
<div class="mt-3 flex h-7 min-w-0 shrink-0 items-center pl-1.5 pr-3">
<div class="text-v2-text-text-faint [font-weight:530]">{props.language.t("home.recentlyClosed")}</div>
</div>
<For each={props.items}>
{(project) => <HomeRecentlyClosedRow {...props} project={project} server={props.server} />}
</For>
</Show>
</div>
)
}
function HomeRecentlyClosedRow(
props: HomeProjectsViewProps & {
project: LocalProject
server: ServerConnection.Any
},
) {
const unreachable = () => props.serverHealth(props.server)?.healthy === false
const path = () => {
const home = props.homedir()
const worktree = props.project.worktree
if (home && (worktree === home || worktree.startsWith(`${home}/`))) return `~${worktree.slice(home.length)}`
return worktree
}
return (
<TooltipV2 placement="right" value={path()}>
<button
type="button"
data-component="home-recently-closed-row"
class={`${HOME_PROJECT_NAV_ROW} disabled:opacity-60`}
disabled={unreachable()}
onClick={() => props.onAddProjects(props.server, [props.project.worktree])}
>
<HomeProjectAvatar project={props.project} outline />
<span class={HOME_PROJECT_NAV_LABEL}>{displayName(props.project)}</span>
</button>
</TooltipV2>
)
}
function HomeProjectRow(
props: HomeProjectsViewProps & {
project: LocalProject
server: ServerConnection.Any
index: () => number
serverSelected: boolean
selected: boolean
unseen: number
},
) {
const serverUnreachable = () => props.serverHealth(props.server)?.healthy === false
const sortable = useSortable({
get id() {
return props.project.worktree
},
get index() {
return props.index()
},
})
let pointerDownSelected: boolean | undefined
const menuID = () => props.projectMenuID(props.server, props.project.worktree)
return (
<div
ref={sortable.ref}
class="group/project relative flex h-7 min-w-0 items-center rounded-[6px]"
classList={{ "z-10": sortable.isDragSource() }}
>
<button
type="button"
data-component="home-project-row"
class={`${HOME_PROJECT_NAV_ROW} pr-16 disabled:opacity-60`}
classList={{
"bg-v2-background-bg-layer-01 text-v2-text-text-base [box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)]":
sortable.isDragSource(),
}}
data-selected={props.selected ? "" : undefined}
aria-current={props.selected ? "page" : undefined}
disabled={serverUnreachable()}
onPointerDown={(event) => {
// Same-server mouse selection happens on pointerdown (like tabs),
// but only ever selects; selectProject toggles, and deselecting here
// would fire on every drag before the threshold is met. Cross-server
// selection waits for click so reordering a remote server's projects
// does not focus that server and load its session index. Touch is
// excluded so flick-scrolling the list cannot select rows.
pointerDownSelected = undefined
if (event.button !== 0 || event.pointerType === "touch") return
if (!props.serverSelected) return
pointerDownSelected = props.selected
if (!props.selected) props.onSelectProject(props.server, props.project.worktree)
}}
onClick={(event) => {
// The drag sensor calls preventDefault on post-drag clicks; never
// toggle selection as part of a reorder.
if (event.defaultPrevented) return
// Keyboard activation and touch taps keep the original toggle.
if (event.detail === 0 || pointerDownSelected === undefined) {
props.onSelectProject(props.server, props.project.worktree)
return
}
// Mouse: pointerdown already selected unselected rows; a plain click
// on an already-selected row toggles it off.
if (pointerDownSelected) props.onSelectProject(props.server, props.project.worktree)
pointerDownSelected = undefined
}}
>
<HomeProjectAvatar project={props.project} />
<span class={HOME_PROJECT_NAV_LABEL}>{displayName(props.project)}</span>
</button>
<div
class="hover-reveal absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-1 group-hover/project:opacity-100 focus-within:opacity-100 data-[menu=true]:opacity-100"
data-menu={props.menuOpen(menuID())}
>
<MenuV2
gutter={6}
modal={false}
placement="bottom-end"
open={props.menuOpen(menuID())}
onOpenChange={(open) => props.onSetMenuOpen(menuID(), open)}
>
<MenuV2.Trigger
as={IconButtonV2}
data-action="home-project-menu"
variant="ghost-muted"
size="small"
icon={<IconV2 name="outline-dots" />}
aria-label={props.language.t("common.moreOptions")}
/>
<MenuV2.Portal>
<MenuV2.Content>
<MenuV2.Item onSelect={() => props.onOpenProjectNewSession(props.server, props.project.worktree)}>
{props.language.t("command.session.new")}
</MenuV2.Item>
<MenuV2.Item onSelect={() => props.onEditProject(props.server, props.project)}>
{props.language.t("dialog.project.edit.title")}
</MenuV2.Item>
<Show when={props.canRevealProject(props.server)}>
<MenuV2.Item onSelect={() => props.onRevealProject(props.server, props.project)}>
{props.fileManagerActionLabel()}
</MenuV2.Item>
</Show>
<MenuV2.Item
disabled={props.unseen === 0}
onSelect={() => props.onClearNotifications(props.server, props.project)}
>
{props.language.t("sidebar.project.clearNotifications")}
</MenuV2.Item>
<MenuV2.Separator />
<MenuV2.Item onSelect={() => props.onCloseProject(props.server, props.project.worktree)}>
{props.language.t("common.close")}
</MenuV2.Item>
</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.onOpenProjectNewSession(props.server, props.project.worktree)}
/>
</div>
</div>
)
}
function HomeProjectAvatar(props: { project: LocalProject; outline?: boolean }) {
const name = createMemo(() => displayName(props.project))
return (
<ProjectAvatar
fallback={name()}
src={props.outline ? undefined : getProjectAvatarSource(props.project.id, props.project.icon)}
variant={props.outline ? "outline" : getProjectAvatarVariant(props.project.icon?.color)}
/>
)
}
@@ -0,0 +1,578 @@
import type { Session } from "@opencode-ai/sdk/v2/client"
import { preloadMarkdown } from "@opencode-ai/session-ui/markdown-cache"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useMarked } from "@opencode-ai/ui/context/marked"
import { makeEventListener } from "@solid-primitives/event-listener"
import { useQuery } from "@tanstack/solid-query"
import { DateTime } from "luxon"
import {
type Accessor,
createEffect,
createMemo,
createRoot,
createSignal,
type JSX,
on,
onCleanup,
startTransition,
} from "solid-js"
import { createStore, produce } from "solid-js/store"
import { useCommand } from "@/context/command"
import {
loadHomeSessionIndex,
retainHomeSessions,
type HomeSessionEvents,
} from "@/context/global-sync/home-session-index"
import type { LocalProject } from "@/context/layout"
import { useLanguage } from "@/context/language"
import { ServerConnection, serverName } from "@/context/server"
import { sessionHasOpenTab, useTabs } from "@/context/tabs"
import { displayName, errorMessage, projectForSession } from "@/pages/layout/helpers"
import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state"
import { pathKey } from "@/utils/path-key"
import { showToast } from "@/utils/toast"
import { Binary } from "@opencode-ai/core/util/binary"
import { archiveHomeSession } from "../home-session-archive"
import type { HomeProjectsController } from "./home-projects-controller"
const HOME_SESSION_LIMIT = 64
const HOME_SESSION_HEADER_STICKY_TOP = 12
const HOME_SESSION_HEADER_TEXT_HEIGHT = 16
const HOME_SESSION_HEADER_FADE_DISTANCE = 16
export type HomeSessionRecord = {
session: Session
project: LocalProject
projectName: string
}
export type HomeSessionGroup = {
id: "today" | "yesterday" | "older"
title: string
sessions: HomeSessionRecord[]
}
export type OpenSessionOptions = { background?: boolean }
export function createHomeSessionsController(projects: HomeProjectsController) {
const tabs = useTabs()
const command = useCommand()
const dialog = useDialog()
const language = useLanguage()
const marked = useMarked()
const [state, setState] = createStore({ search: "", searchFocused: false, active: "" })
const [thumbTrack, setThumbTrack] = createSignal<HTMLDivElement>()
const [hoverTarget, setHoverTarget] = createSignal<HTMLElement>()
let viewport: HTMLDivElement | undefined
let searchRoot: HTMLDivElement | undefined
let searchInput: HTMLInputElement | undefined
let searchList: HTMLDivElement | undefined
const search = createMemo(() => state.search.trim())
const projectDirectories = createMemo(() => {
const project = projects.selectedProject()
if (!project) return projects.projects().flatMap(directories)
return directories(project)
})
const projectByID = createMemo(
() => new Map(projects.projects().flatMap((project) => (project.id ? [[project.id, project] as const] : []))),
)
const homeSessions = () => projects.focusedSync().homeSessions
const sessionEventLoad = useQuery(() => ({
queryKey: homeSessions().eventsKey,
queryFn: async (): Promise<HomeSessionEvents> => ({ sequence: 0, entries: [] }),
initialData: { sequence: 0, entries: [] } satisfies HomeSessionEvents,
enabled: false,
}))
const sessionLoad = useQuery(() => ({
queryKey: homeSessions().indexKey,
enabled: !!projects.focusedServerCtx(),
queryFn: async ({ signal }) => {
const ctx = projects.focusedServerCtx()
if (!ctx) return { sessions: [], eventSequence: 0 }
const cache = homeSessions()
const eventSequence = cache.eventSequence()
const index = await loadHomeSessionIndex(
(input, options) => ctx.sdk.client.v2.session.list(input, options),
eventSequence,
signal,
)
cache.complete(eventSequence)
return index
},
retry: false,
staleTime: 30_000,
refetchOnMount: true,
refetchOnReconnect: true,
}))
const indexedSessions = createMemo(() =>
retainHomeSessions(
homeSessions().sessions(sessionLoad.data, sessionEventLoad.data),
HOME_SESSION_LIMIT,
Date.now(),
),
)
const allRecords = createMemo(() =>
buildHomeSessionRecords({
sessions: indexedSessions,
projectDirectories,
projects: projects.projects,
projectByID,
}),
)
const records = createMemo(() => allRecords().slice(0, HOME_SESSION_LIMIT))
const searchResults = createMemo(() => {
const query = search().toLowerCase()
if (!query) return []
return allRecords().filter((record) => matchesHomeSessionSearch(record, query))
})
const searchOpen = createMemo(() => state.searchFocused && search().length > 0)
const searchPlaceholder = createMemo(() => {
const project = projects.selectedProject()
if (project) return language.t("home.sessions.search.placeholder.scoped", { scope: displayName(project) })
if (projects.servers().length > 1) {
const conn = projects.focusedServer()
if (conn) return language.t("home.sessions.search.placeholder.scoped", { scope: serverName(conn) })
}
return language.t("home.sessions.search.placeholder")
})
const groups = createMemo(() => groupSessions(records(), language))
const header = createHomeSessionHeaderController(groups)
const prefetched = new Set<string>()
createEffect(() => {
const ctx = projects.focusedServerCtx()
const conn = projects.focusedServer()
if (!ctx || !conn) return
records()
.slice(0, 2)
.forEach((record) => {
const key = `${ServerConnection.key(conn)}\0${record.session.id}`
if (prefetched.has(key)) return
prefetched.add(key)
createRoot((dispose) => {
try {
void ctx.sync.session
.sync(record.session.id)
.then(() =>
Promise.all(
(ctx.sync.session.data.message[record.session.id] ?? []).flatMap((message) =>
(ctx.sync.session.data.part[message.id] ?? []).flatMap((part) => {
if (part.type !== "text" || !part.text) return []
return preloadMarkdown(part.text, part.id, marked)
}),
),
),
)
.catch(() => {})
.finally(dispose)
} catch {
dispose()
}
})
})
})
createEffect(() => syncActive(searchResults()))
createEffect(
on(
() => state.search,
() => syncActive(searchResults()),
),
)
onCleanup(
makeEventListener(document, "pointerdown", (event) => {
if (!searchOpen()) return
const target = event.target
if (!(target instanceof Node) || searchRoot?.contains(target)) return
closeSearch()
}),
)
command.register("home", () => [
{
id: "command.palette",
title: language.t("command.palette"),
hidden: true,
onSelect: async () => {
const conn = projects.focusedServer()
if (!conn) return
const ctx = projects.focusedServerCtx()
if (!ctx) return
const { DialogHomeCommandPaletteV2 } = await import("@/components/dialog-command-palette-v2")
void dialog.show(() => (
<DialogHomeCommandPaletteV2
server={conn}
onSelectSession={(entry) => {
if (!entry.sessionID || !entry.directory || !entry.server) return
const sessionID = entry.sessionID
const server = entry.server
const directory = entry.project?.worktree ?? entry.directory
ctx.projects.open(directory)
ctx.projects.touch(directory)
void startTransition(() => {
const tab = tabs.addSessionTab({ server, sessionId: sessionID })
tabs.select(tab)
})
}}
/>
))
},
},
{
id: "home.sessions.search.focus",
title: searchPlaceholder(),
keybind: "mod+f",
hidden: true,
onSelect: focusSearch,
},
])
function syncActive(results: HomeSessionRecord[]) {
if (results.length === 0) {
setState("active", "")
return
}
if (!results.some((record) => homeSessionSearchKey(record) === state.active)) {
setState("active", homeSessionSearchKey(results[0]))
}
}
function focusSearch() {
searchInput?.focus()
setState("searchFocused", true)
}
function closeSearch() {
setState("search", "")
setState("searchFocused", false)
}
function moveActive(delta: number) {
const results = searchResults()
if (results.length === 0) return
const index = results.findIndex((record) => homeSessionSearchKey(record) === state.active)
const next = ((index === -1 ? 0 : index) + delta + results.length) % results.length
setState("active", homeSessionSearchKey(results[next]))
searchList?.querySelector<HTMLElement>(`[data-key="${state.active}"]`)?.scrollIntoView({ block: "nearest" })
}
function selectSearchSession(record: HomeSessionRecord, options?: OpenSessionOptions) {
openSession(record.session, options)
if (!options?.background) closeSearch()
}
function openSession(session: Session, options?: OpenSessionOptions) {
const directoryKey = pathKey(session.directory)
const project =
projects
.projects()
.find(
(item) =>
pathKey(item.worktree) === directoryKey ||
item.sandboxes?.some((sandbox) => pathKey(sandbox) === directoryKey),
) ?? projectForSession(session, projects.projects(), projectByID())
const conn = projects.focusedServer()
if (!conn) return
const directory = project?.worktree ?? session.directory
const ctx = projects.focusedServerCtx()
if (!ctx) return
ctx.projects.open(directory)
if (options?.background) {
tabs.addSessionTab({ server: ServerConnection.key(conn), sessionId: session.id })
return
}
ctx.projects.touch(directory)
void startTransition(() => {
const tab = tabs.addSessionTab({ server: ServerConnection.key(conn), sessionId: session.id })
tabs.select(tab)
})
}
async function archiveSession(session: Session) {
const conn = projects.focusedServer()
const ctx = projects.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, (item) => item.id)
if (match.found) draft.session.splice(match.index, 1)
}),
),
onError: (cause) =>
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(cause, language.t("common.requestFailed")),
}),
})
}
function setViewport(element: HTMLDivElement) {
viewport = element
header.setViewport(element)
}
function containWheel(event: WheelEvent) {
if (!viewport) return
if (event.defaultPrevented || event.ctrlKey || !event.deltaY) return
if (!(event.target instanceof Element)) return
const scrollable = event.target.closest<HTMLElement>("[data-scrollable]")
if (
scrollable !== viewport &&
scrollable &&
(event.deltaY < 0
? scrollable.scrollTop > 0
: scrollable.scrollTop < scrollable.scrollHeight - scrollable.clientHeight)
)
return
event.preventDefault()
}
return {
language,
records,
groups,
loading: () => sessionLoad.isLoading,
showProjectName: () => !projects.selectedProject(),
server: () => projects.selection().server,
canCreateSession: () => !!projects.newSessionProject(),
createSession: projects.openNewSession,
openSession,
archiveSession,
hasOpenTab: (record: HomeSessionRecord) =>
sessionHasOpenTab(tabs.store, projects.selection().server, record.session),
header,
scroll: {
thumbTrack,
hoverTarget,
setThumbTrack,
setHoverTarget,
setViewport,
update: (scrollTop: number) => header.update(scrollTop),
containWheel,
containOuterWheel: (event: WheelEvent) => {
if (!viewport) return
if (event.target instanceof Node && viewport.contains(event.target)) return
containWheel(event)
},
},
search: {
value: () => state.search,
query: search,
placeholder: searchPlaceholder,
open: searchOpen,
loading: () => sessionLoad.isLoading,
results: searchResults,
active: () => state.active,
noResultsLabel: () => language.t("home.sessions.search.noResults", { query: search() }),
setRoot: (element: HTMLDivElement) => (searchRoot = element),
setInput: (element: HTMLInputElement) => (searchInput = element),
setList: (element: HTMLDivElement) => (searchList = element),
input: (value: string) => setState("search", value),
focus: focusSearch,
close: closeSearch,
highlight: (record: HomeSessionRecord) => setState("active", homeSessionSearchKey(record)),
move: moveActive,
select: selectSearchSession,
selectActive: () => {
const record = searchResults().find((item) => homeSessionSearchKey(item) === state.active)
if (record) selectSearchSession(record)
},
},
}
}
function directories(project: LocalProject) {
return [project.worktree, ...(project.sandboxes ?? [])]
}
function buildHomeSessionRecords(input: {
sessions: () => Session[]
projectDirectories: () => string[]
projects: () => LocalProject[]
projectByID: () => Map<string, LocalProject>
}) {
const directories = new Set(input.projectDirectories().map(pathKey))
const sessions = input.sessions().filter((session) => directories.has(pathKey(session.directory)))
return [...new Map(sessions.map((session) => [session.id, session] as const)).values()]
.sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
.flatMap((session) => {
const directory = pathKey(session.directory)
const project =
input
.projects()
.find(
(item) =>
pathKey(item.worktree) === directory || item.sandboxes?.some((sandbox) => pathKey(sandbox) === directory),
) ?? projectForSession(session, input.projects(), input.projectByID())
if (!project) return []
return { session, project, projectName: displayName(project) }
})
}
function matchesHomeSessionSearch(record: HomeSessionRecord, query: string) {
return `${record.session.title} ${record.projectName}`.toLowerCase().includes(query)
}
export function homeSessionSearchKey(record: HomeSessionRecord) {
return `${pathKey(record.session.directory)}:${record.session.id}`
}
function groupSessions(records: HomeSessionRecord[], language: ReturnType<typeof useLanguage>): HomeSessionGroup[] {
const now = DateTime.local()
const yesterday = now.minus({ days: 1 })
const todaySessions = records.filter((record) =>
DateTime.fromMillis(record.session.time.updated ?? record.session.time.created).hasSame(now, "day"),
)
const yesterdaySessions = records.filter((record) =>
DateTime.fromMillis(record.session.time.updated ?? record.session.time.created).hasSame(yesterday, "day"),
)
const olderSessions = records.filter((record) => {
const time = DateTime.fromMillis(record.session.time.updated ?? record.session.time.created)
return !time.hasSame(now, "day") && !time.hasSame(yesterday, "day")
})
const olderTitle =
todaySessions.length === 0 && yesterdaySessions.length === 0
? language.t("sidebar.project.recentSessions")
: language.t("home.sessions.group.older")
return [
{ id: "today" as const, title: language.t("home.sessions.group.today"), sessions: todaySessions },
{ id: "yesterday" as const, title: language.t("home.sessions.group.yesterday"), sessions: yesterdaySessions },
{ id: "older" as const, title: olderTitle, sessions: olderSessions },
].filter((group) => group.sessions.length > 0)
}
function createHomeSessionHeaderController(groups: () => HomeSessionGroup[]) {
let viewport: HTMLDivElement | undefined
let content: HTMLDivElement | undefined
let positionFrame: number | undefined
let resizeObserver: ResizeObserver | undefined
let stickyTop = HOME_SESSION_HEADER_STICKY_TOP
const headerRefs = new Map<HomeSessionGroup["id"], HTMLDivElement>()
const headerOffsets = new Map<HomeSessionGroup["id"], number>()
const [state, setState] = createStore({
titleOpacity: {} as Partial<Record<HomeSessionGroup["id"], number>>,
})
createEffect(() => {
const items = groups()
const ids = new Set(items.map((group) => group.id))
headerRefs.forEach((_, id) => {
if (!ids.has(id)) headerRefs.delete(id)
})
headerOffsets.forEach((_, id) => {
if (!ids.has(id)) headerOffsets.delete(id)
})
if (items.length === 0) {
content = undefined
bindResizeObserver()
}
queuePositionUpdate()
})
onCleanup(() => {
if (positionFrame !== undefined) cancelAnimationFrame(positionFrame)
resizeObserver?.disconnect()
})
function setViewport(element: HTMLDivElement) {
viewport = element
bindResizeObserver()
queuePositionUpdate()
}
function setContent(element: HTMLDivElement) {
content = element
bindResizeObserver()
queuePositionUpdate()
}
function setHeader(id: HomeSessionGroup["id"], element: HTMLDivElement) {
headerRefs.set(id, element)
queuePositionUpdate()
}
function queuePositionUpdate() {
if (typeof requestAnimationFrame === "undefined") {
updatePositionCache()
return
}
if (positionFrame !== undefined) return
positionFrame = requestAnimationFrame(() => {
positionFrame = undefined
updatePositionCache()
})
}
function updatePositionCache() {
if (!viewport) return
const header = groups()
.map((group) => headerRefs.get(group.id))
.find((element) => element !== undefined)
if (header && typeof getComputedStyle === "function") {
const top = Number.parseFloat(getComputedStyle(header).top)
if (Number.isFinite(top)) stickyTop = top
}
groups().forEach((group) => {
const element = headerRefs.get(group.id)
if (element) headerOffsets.set(group.id, element.offsetTop)
})
update(viewport.scrollTop)
}
function update(scrollTop: number) {
const items = groups()
items.forEach((group, index) => {
const nextOffset = items
.slice(index + 1)
.map((item) => headerOffsets.get(item.id))
.find((offset) => offset !== undefined)
const fadeEnd = stickyTop + HOME_SESSION_HEADER_TEXT_HEIGHT
const nextTop = nextOffset === undefined ? undefined : nextOffset - scrollTop
const opacity =
nextTop === undefined ? 1 : Math.max(0, Math.min(1, (nextTop - fadeEnd) / HOME_SESSION_HEADER_FADE_DISTANCE))
setState("titleOpacity", group.id, Math.round(opacity * 1000) / 1000)
})
}
function bindResizeObserver() {
resizeObserver?.disconnect()
if (typeof ResizeObserver === "undefined") return
resizeObserver = new ResizeObserver(queuePositionUpdate)
if (viewport) resizeObserver.observe(viewport)
if (content) resizeObserver.observe(content)
}
return {
setViewport,
setContent,
setHeader,
update,
titleOpacity: (id: HomeSessionGroup["id"]) => state.titleOpacity[id] ?? 1,
}
}
export type HomeSessionsController = ReturnType<typeof createHomeSessionsController>
export function HomeSessionStatusController(props: {
server: Accessor<ServerConnection.Key>
record: HomeSessionRecord
isOpenTab: (record: HomeSessionRecord) => boolean
render: (state: { unread: Accessor<boolean>; loading: Accessor<boolean>; open: Accessor<boolean> }) => JSX.Element
}) {
const avatar = useSessionTabAvatarState(
props.server,
() => props.record.session.directory,
() => props.record.session.id,
)
return props.render({
unread: avatar.unread,
loading: avatar.loading,
open: () => props.isOpenTab(props.record),
})
}
@@ -0,0 +1,491 @@
import type { Session } from "@opencode-ai/sdk/v2/client"
import { type Accessor, createMemo, For, Show } from "solid-js"
import { Spinner } from "@opencode-ai/ui/spinner"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
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 { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/server"
import { SessionTabAvatarView } from "@/pages/layout/session-tab-avatar"
import { sessionTitle } from "@/utils/session-title"
import { shouldOpenSessionInBackground } from "../home-session-open"
import {
HomeSessionStatusController,
homeSessionSearchKey,
type HomeSessionGroup,
type HomeSessionRecord,
type OpenSessionOptions,
} from "./home-sessions-controller"
const SHOW_HOME_SESSION_ARCHIVE = false
const HOME_ROW_LAYOUT =
"flex min-w-0 w-full shrink-0 cursor-default items-center rounded-[6px] bg-transparent text-left transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out focus-visible:outline-none"
const HOME_ROW_BASE = `${HOME_ROW_LAYOUT} border-0`
const HOME_ROW = `${HOME_ROW_BASE} [font-weight:530] text-v2-text-text-muted hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover`
const HOME_SECTION_LABEL = "text-v2-text-text-muted [font-weight:440]"
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-[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 =
"min-w-0 flex-[1_1_auto] overflow-hidden text-ellipsis whitespace-nowrap text-[13px] leading-4 tracking-[-0.04px] text-v2-text-text-muted [font-weight:440]"
// Middle-click or Cmd+click on macOS (Ctrl+click elsewhere) opens a session
// tab in the background without navigating, matching browser conventions.
function isBackgroundOpen(event: MouseEvent) {
return shouldOpenSessionInBackground({
button: event.button,
mac: typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform),
meta: event.metaKey,
ctrl: event.ctrlKey,
shift: event.shiftKey,
alt: event.altKey,
})
}
export type HomeSessionsViewProps = {
language: ReturnType<typeof useLanguage>
groups: Accessor<HomeSessionGroup[]>
loading: Accessor<boolean>
showProjectName: Accessor<boolean>
server: Accessor<ServerConnection.Key>
canCreateSession: Accessor<boolean>
searchValue: Accessor<string>
searchPlaceholder: Accessor<string>
searchOpen: Accessor<boolean>
searchLoading: Accessor<boolean>
searchResults: Accessor<HomeSessionRecord[]>
searchActive: Accessor<string>
searchNoResultsLabel: Accessor<string>
titleOpacity: (id: HomeSessionGroup["id"]) => number
isOpenTab: (record: HomeSessionRecord) => boolean
onCreateSession: () => void
onOpenSession: (session: Session, options?: OpenSessionOptions) => void
onArchiveSession: (session: Session) => Promise<void>
onSetHoverTarget: (element: HTMLElement) => void
onSetThumbTrack: (element: HTMLDivElement) => void
onSetContent: (element: HTMLDivElement) => void
onSetHeader: (id: HomeSessionGroup["id"], element: HTMLDivElement) => void
onWheel: (event: WheelEvent) => void
onSetSearchRoot: (element: HTMLDivElement) => void
onSetSearchInput: (element: HTMLInputElement) => void
onSetSearchList: (element: HTMLDivElement) => void
onSearchFocus: () => void
onSearchInput: (value: string) => void
onSearchClose: () => void
onSearchMove: (delta: number) => void
onSearchSelectActive: () => void
onSearchHighlight: (record: HomeSessionRecord) => void
onSearchSelect: (record: HomeSessionRecord, options?: OpenSessionOptions) => void
}
export function HomeSessionsView(props: HomeSessionsViewProps) {
return (
<section
ref={props.onSetHoverTarget}
class="min-h-0 min-w-0 flex-1 flex flex-col"
aria-label={props.language.t("sidebar.project.recentSessions")}
>
<div class="sticky top-0 z-30 shrink-0 bg-v2-background-bg-base pb-3 pt-6 lg:pt-12" onWheel={props.onWheel}>
<HomeSessionSearch {...props} />
<Show when={props.groups().length > 0 && props.canCreateSession()}>
<div class="pointer-events-none absolute right-0 top-[84px] z-20 flex lg:top-[108px]">
<ButtonV2
data-action="home-new-session"
variant="ghost-muted"
size="normal"
icon="edit"
class="pointer-events-auto h-7 px-2 [font-weight:530]"
onClick={props.onCreateSession}
>
{props.language.t("command.session.new")}
</ButtonV2>
</div>
</Show>
</div>
<div class="pointer-events-none sticky top-[84px] z-40 h-0 -mr-3 lg:top-[108px]">
<div
ref={props.onSetThumbTrack}
data-component="home-session-scroll-track"
class="relative ml-auto h-[calc(100cqh-84px)] w-3 lg:h-[calc(100cqh-108px)]"
/>
</div>
<div class="-mr-3 min-h-[calc(100cqh-72px)] lg:min-h-[calc(100cqh-96px)]">
<Show
when={!props.loading()}
fallback={
<div class="pt-3">
<HomeSessionSkeleton label={props.language.t("common.loading")} />
</div>
}
>
<Show
when={props.groups().length > 0}
fallback={
<HomeSessionsEmpty
onNewSession={props.canCreateSession() ? props.onCreateSession : undefined}
language={props.language}
/>
}
>
<div ref={props.onSetContent} class="flex flex-col pt-3 pr-3 pb-16">
<For each={props.groups()}>
{(group, index) => (
<>
<HomeSessionGroupHeader
title={group.title}
titleOpacity={props.titleOpacity(group.id)}
onSetRef={(element) => props.onSetHeader(group.id, element)}
elevated={index() === 0}
/>
<div
class={`flex min-w-0 flex-col gap-px pt-4 ${index() === props.groups().length - 1 ? "" : "mb-6"}`}
>
<For each={group.sessions}>{(record) => <HomeSessionRow {...props} record={record} />}</For>
</div>
</>
)}
</For>
</div>
</Show>
</Show>
</div>
</section>
)
}
function HomeSessionLeadingController(props: {
server: HomeSessionsViewProps["server"]
isOpenTab: HomeSessionsViewProps["isOpenTab"]
record: HomeSessionRecord
revealProjectOnHover: boolean
}) {
return (
<HomeSessionStatusController
server={props.server}
record={props.record}
isOpenTab={props.isOpenTab}
render={(state) => (
<HomeSessionLeading
record={props.record}
revealProjectOnHover={props.revealProjectOnHover}
open={state.open()}
unread={state.unread()}
loading={state.loading()}
/>
)}
/>
)
}
function HomeSessionLeading(props: {
record: HomeSessionRecord
revealProjectOnHover: boolean
open: boolean
unread: boolean
loading: boolean
}) {
return (
<div class="relative shrink-0">
<Show when={props.open}>
<span
aria-hidden="true"
class="pointer-events-none absolute top-1/2 h-3 w-0.5 -translate-y-1/2 rounded-[2px] bg-v2-background-bg-layer-04"
style={{ right: "calc(100% + 4px)" }}
/>
</Show>
<SessionTabAvatarView
project={props.record.project}
directory={props.record.session.directory}
revealProjectOnHover={props.revealProjectOnHover}
unread={props.unread}
loading={props.loading}
/>
</div>
)
}
function HomeSessionSearch(props: HomeSessionsViewProps) {
return (
<div class="w-full">
<div ref={props.onSetSearchRoot} data-component="home-session-search" class="relative z-30 w-full">
<Show when={props.searchOpen()}>
<div
data-component="home-session-search-panel"
class="absolute flex flex-col overflow-hidden rounded-[12px] bg-v2-background-bg-base shadow-[var(--v2-elevation-floating)]"
style={{ top: "-6px", left: "-6px", width: "calc(100% + 12px)" }}
>
<div class="flex flex-col pt-9">
<div id={HOME_SESSION_SEARCH_RESULTS_ID} role="listbox" class="flex flex-col gap-4 pt-4">
<Show
when={!props.searchLoading()}
fallback={
<div class="flex items-center justify-center px-4 py-3 text-v2-text-text-muted [font-weight:440]">
<Spinner class="size-4" />
</div>
}
>
<Show
when={props.searchResults().length > 0}
fallback={
<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.searchNoResultsLabel()}
</p>
}
>
<div class="flex flex-col">
<p class="my-1.5 pl-[18px] pr-6 text-[13px] leading-4 tracking-[-0.04px] text-v2-text-text-muted [font-weight:440]">
{props.language.t("home.sessions.search.sessions")}
</p>
<ScrollView class="max-h-80" viewportRef={props.onSetSearchList}>
<div class="flex flex-col gap-px pb-2">
<For each={props.searchResults()}>
{(record) => (
<HomeSessionSearchResultRow
{...props}
record={record}
selected={props.searchActive() === homeSessionSearchKey(record)}
/>
)}
</For>
</div>
</ScrollView>
</div>
</Show>
</Show>
</div>
</div>
</div>
</Show>
<label class="relative z-20 flex h-9 w-full items-center gap-2 rounded-[6px] bg-v2-background-bg-layer-02/60 py-1 pl-3 pr-2 text-v2-icon-icon-muted transition-[background-color,box-shadow] duration-[120ms] ease-in-out hover:bg-v2-background-bg-layer-02 focus-within:bg-v2-background-bg-layer-02">
<IconV2 name="magnifying-glass" />
<input
ref={props.onSetSearchInput}
class="relative z-20 min-w-0 flex-1 border-0 bg-transparent text-v2-text-text-base outline-0 [font-weight:440] placeholder:text-v2-text-text-faint"
value={props.searchValue()}
placeholder={props.searchPlaceholder()}
aria-label={props.searchPlaceholder()}
aria-expanded={props.searchOpen()}
aria-controls={HOME_SESSION_SEARCH_RESULTS_ID}
aria-autocomplete="list"
aria-activedescendant={
props.searchActive() && props.searchOpen()
? `home-session-search-option-${props.searchActive()}`
: undefined
}
onFocus={props.onSearchFocus}
onInput={(event) => props.onSearchInput(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault()
props.onSearchClose()
event.currentTarget.blur()
return
}
if (!props.searchOpen() || props.searchResults().length === 0) return
if (event.altKey || event.metaKey) return
if (event.key === "ArrowDown") {
event.preventDefault()
props.onSearchMove(1)
return
}
if (event.key === "ArrowUp") {
event.preventDefault()
props.onSearchMove(-1)
return
}
if (event.key === "Enter" && !event.isComposing) {
event.preventDefault()
props.onSearchSelectActive()
}
}}
/>
<Show when={props.searchValue()}>
<IconButtonV2
type="button"
variant="ghost-muted"
size="small"
class="relative z-20 shrink-0"
icon={<IconV2 name="close" size="large" class="text-v2-icon-icon-muted" />}
aria-label={props.searchPlaceholder()}
onClick={() => {
props.onSearchClose()
props.onSearchFocus()
}}
/>
</Show>
</label>
</div>
</div>
)
}
function HomeSessionSearchResultRow(
props: HomeSessionsViewProps & {
record: HomeSessionRecord
selected: boolean
},
) {
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
const showProjectName = () => props.showProjectName() && props.record.projectName
const key = () => homeSessionSearchKey(props.record)
return (
<button
type="button"
id={`home-session-search-option-${key()}`}
data-key={key()}
data-component="home-session-search-row"
role="option"
aria-selected={props.selected}
classList={{
[HOME_SEARCH_RESULT_ROW]: true,
"bg-v2-overlay-simple-overlay-hover": props.selected,
group: !!showProjectName(),
}}
onMouseEnter={() => props.onSearchHighlight(props.record)}
onMouseDown={(event) => {
if (event.button === 1) event.preventDefault()
}}
onClick={(event) => props.onSearchSelect(props.record, { background: isBackgroundOpen(event) })}
onAuxClick={(event) => {
if (!isBackgroundOpen(event)) return
event.preventDefault()
props.onSearchSelect(props.record, { background: true })
}}
>
<HomeSessionLeadingController
server={props.server}
isOpenTab={props.isOpenTab}
record={props.record}
revealProjectOnHover={!!showProjectName()}
/>
<div class="flex min-w-0 flex-1 items-center gap-1.5">
<span
class={`${HOME_SEARCH_RESULT_TITLE} ${showProjectName() ? "max-w-[min(70%,480px)] flex-[0_1_auto]" : "flex-[1_1_auto]"}`}
>
{title()}
</span>
<Show when={showProjectName()}>
<span class={HOME_SEARCH_RESULT_META}>{props.record.projectName}</span>
</Show>
</div>
</button>
)
}
function HomeSessionGroupHeader(props: {
title: string
titleOpacity: number
onSetRef: (element: HTMLDivElement) => void
elevated?: boolean
}) {
return (
<div
ref={props.onSetRef}
class={`pointer-events-none sticky top-[84px] lg:top-[108px] flex h-7 min-w-0 items-center justify-between pl-3 bg-v2-background-bg-base ${props.elevated ? "home-session-group-header z-[5]" : "z-10"}`}
>
<div class={HOME_SECTION_LABEL} style={{ opacity: props.titleOpacity }}>
{props.title}
</div>
</div>
)
}
function HomeSessionRow(props: HomeSessionsViewProps & { record: HomeSessionRecord }) {
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
const showProjectName = () => props.showProjectName() && props.record.projectName
return (
<div
class="group/session relative flex h-10 min-w-0 items-center rounded-[6px]"
classList={{ group: !!showProjectName() }}
>
<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`}
onMouseDown={(event) => {
if (event.button === 1) event.preventDefault()
}}
onClick={(event) => props.onOpenSession(props.record.session, { background: isBackgroundOpen(event) })}
onAuxClick={(event) => {
if (!isBackgroundOpen(event)) return
event.preventDefault()
props.onOpenSession(props.record.session, { background: true })
}}
>
<HomeSessionLeadingController
server={props.server}
isOpenTab={props.isOpenTab}
record={props.record}
revealProjectOnHover={!!showProjectName()}
/>
<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 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>
<Show when={SHOW_HOME_SESSION_ARCHIVE}>
<div class="hover-reveal absolute right-1.5 top-1/2 flex -translate-y-1/2 items-center gap-1 group-hover/session:opacity-100 focus-within:opacity-100">
<TooltipV2 class="flex shrink-0 items-center" placement="bottom" value={props.language.t("common.archive")}>
<IconButtonV2
data-action="home-session-archive"
variant="ghost-muted"
size="large"
icon={<IconV2 name="archive" />}
aria-label={props.language.t("common.archive")}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
void props.onArchiveSession(props.record.session)
}}
/>
</TooltipV2>
</div>
</Show>
</div>
)
}
function HomeSessionsEmpty(props: { onNewSession?: () => void; language: ReturnType<typeof useLanguage> }) {
return (
<div class="flex min-h-full flex-col items-center gap-4 px-6 pt-[52px] text-center">
<div class="shrink-0 text-[13px] leading-[13px] tracking-[-0.04px] text-v2-text-text-base [font-weight:530]">
{props.language.t("home.sessions.empty")}
</div>
<p class="mb-1 text-center text-[13px] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-weight:440]">
{props.language.t("home.sessions.empty.description")}
</p>
<Show when={props.onNewSession}>
{(onNewSession) => (
<ButtonV2 data-action="home-new-session" variant="neutral" size="normal" icon="edit" onClick={onNewSession()}>
{props.language.t("command.session.new")}
</ButtonV2>
)}
</Show>
</div>
)
}
function HomeSessionSkeleton(props: { label: string }) {
return (
<div class="flex min-w-0 flex-col gap-4">
<div class="flex h-7 min-w-0 items-center justify-between px-4">
<div class={HOME_SECTION_LABEL}>{props.label}</div>
</div>
<div class="flex min-w-0 flex-col gap-px" aria-hidden="true">
<For each={[0, 1, 2, 3]}>{() => <div class="h-10 rounded-[6px] bg-v2-background-bg-deep opacity-70" />}</For>
</div>
</div>
)
}
+142
View File
@@ -0,0 +1,142 @@
import { DialogSelectServer } from "@/components/dialog-select-server"
import { useDirectoryPicker } from "@/components/directory-picker"
import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language"
import { type ServerConnection, useServer } from "@/context/server"
import { useServerSync } from "@/context/server-sync"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Icon } from "@opencode-ai/ui/icon"
import { Logo } from "@opencode-ai/ui/logo"
import { useNavigate } from "@solidjs/router"
import { DateTime } from "luxon"
import { createMemo, For, Match, Switch } from "solid-js"
export function LegacyHome() {
const sync = useServerSync()
const pickDirectory = useDirectoryPicker()
const dialog = useDialog()
const navigate = useNavigate()
const global = useGlobal()
const server = useServer()
const language = useLanguage()
const homedir = createMemo(() => sync().data.path.home)
const serverUnreachable = createMemo(() => global.servers.health[server.key]?.healthy === false)
const recent = createMemo(() => {
return sync()
.data.project.slice()
.sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
.slice(0, 5)
})
const serverDotClass = createMemo(() => {
const healthy = global.servers.health[server.key]?.healthy
if (healthy === true) return "bg-icon-success-base"
if (healthy === false) return "bg-icon-critical-base"
return "bg-border-weak-base"
})
function openProject(conn: ServerConnection.Any, directory: string) {
const serverCtx = global.ensureServerCtx(conn)
serverCtx.projects.open(directory)
serverCtx.projects.touch(directory)
navigate(`/${base64Encode(directory)}`)
}
function chooseProject() {
if (serverUnreachable()) return
const conn = server.current
if (!conn) return
const resolve = (result: string | string[] | null) => {
if (Array.isArray(result)) {
result.forEach((directory) => openProject(conn, directory))
return
}
if (result) openProject(conn, result)
}
pickDirectory({
server: conn,
title: language.t("command.project.open"),
multiple: true,
onSelect: resolve,
})
}
return (
<div class="mx-auto mt-55 w-full md:w-auto px-4">
<Logo class="md:w-xl opacity-12" />
<Button
size="large"
variant="ghost"
class="mt-4 mx-auto text-14-regular text-text-weak"
onClick={() => dialog.show(() => <DialogSelectServer />)}
>
<div
classList={{
"size-2 rounded-full": true,
[serverDotClass()]: true,
}}
/>
{server.name}
</Button>
<Switch>
<Match when={sync().data.project.length > 0}>
<div class="mt-20 w-full flex flex-col gap-4">
<div class="flex gap-2 items-center justify-between pl-3">
<div class="text-14-medium text-text-strong">{language.t("home.recentProjects")}</div>
<Button
icon="folder-add-left"
size="normal"
class="pl-2 pr-3"
disabled={serverUnreachable()}
onClick={chooseProject}
>
{language.t("command.project.open")}
</Button>
</div>
<ul class="flex flex-col gap-2">
<For each={recent()}>
{(project) => (
<Button
size="large"
variant="ghost"
class="text-14-mono text-left justify-between px-3"
onClick={() => openProject(server.current!, project.worktree)}
>
{project.worktree.replace(homedir(), "~")}
<div class="text-14-regular text-text-weak">
{DateTime.fromMillis(project.time.updated ?? project.time.created).toRelative()}
</div>
</Button>
)}
</For>
</ul>
</div>
</Match>
<Match when={!sync().ready}>
<div class="mt-30 mx-auto flex flex-col items-center gap-3">
<div class="text-12-regular text-text-weak">{language.t("common.loading")}</div>
<Button class="px-3" disabled={serverUnreachable()} onClick={chooseProject}>
{language.t("command.project.open")}
</Button>
</div>
</Match>
<Match when={true}>
<div class="mt-30 mx-auto flex flex-col items-center gap-3">
<Icon name="folder-add-left" size="large" />
<div class="flex flex-col gap-1 items-center justify-center">
<div class="text-14-medium text-text-strong">{language.t("home.empty.title")}</div>
<div class="text-12-regular text-text-weak">{language.t("home.empty.description")}</div>
</div>
<Button class="px-3 mt-1" disabled={serverUnreachable()} onClick={chooseProject}>
{language.t("command.project.open")}
</Button>
</div>
</Match>
</Switch>
</div>
)
}
@@ -19,16 +19,34 @@ export function SessionTabAvatar(props: {
() => props.directory,
() => props.sessionId,
)
return (
<SessionTabAvatarView
project={props.project}
directory={props.directory}
revealProjectOnHover={props.revealProjectOnHover}
unread={state.unread()}
loading={state.loading()}
/>
)
}
export function SessionTabAvatarView(props: {
project?: LocalProject
directory: string
revealProjectOnHover?: boolean
unread: boolean
loading: boolean
}) {
const projectAvatar = () => (
<ProjectAvatar
fallback={displayName(props.project ?? { worktree: props.directory })}
src={getProjectAvatarSource(props.project?.id, props.project?.icon)}
variant={getProjectAvatarVariant(props.project?.icon?.color)}
unread={state.unread()}
unread={props.unread}
/>
)
return (
<Show when={state.loading()} fallback={projectAvatar()}>
<Show when={props.loading} fallback={projectAvatar()}>
<span class="relative block size-4 shrink-0">
<SessionProgressIndicatorV2
class={`absolute inset-0 ${props.revealProjectOnHover === false ? "" : "group-hover:invisible"}`}
@@ -62,7 +62,7 @@ import { SessionContextUsage } from "@/components/session-context-usage"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLanguage } from "@/context/language"
import { useSessionKey } from "@/pages/session/session-layout"
import { useServerSDK } from "@/context/server-sdk"
import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import { useTabs } from "@/context/tabs"
@@ -291,7 +291,8 @@ export function MessageTimeline(props: {
const titleValue = createMemo(() => info()?.title)
const titleLabel = createMemo(() => sessionTitle(titleValue()))
const shareUrl = createMemo(() => info()?.share?.url)
const shareEnabled = createMemo(() => sync().data.config.share !== "disabled")
const protocol = useServerProtocol()
const shareEnabled = createMemo(() => protocol() === "v1" && sync().data.config.share !== "disabled")
const parentID = createMemo(() => info()?.parentID)
const parent = createMemo(() => {
const id = parentID()
@@ -19,6 +19,7 @@ import { extractPromptFromParts } from "@/utils/prompt"
import { UserMessage } from "@opencode-ai/sdk/v2"
import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionOwnership } from "./session-ownership"
import { useServerProtocol } from "@/context/server-sdk"
export type SessionCommandContext = {
navigateMessageByOffset: (offset: number) => void
@@ -45,6 +46,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const prompt = usePrompt()
const sdk = useSDK()
const settings = useSettings()
const protocol = useServerProtocol()
const sync = useSync()
const terminal = useTerminal()
const layout = useLayout()
@@ -184,6 +186,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
}
const share = async () => {
if (protocol() !== "v1") return
const sessionID = params.id
if (!sessionID) return
@@ -210,6 +213,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
}
const unshare = async () => {
if (protocol() !== "v1") return
const sessionID = params.id
if (!sessionID) return
@@ -390,6 +394,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
}
const shareCmds = () => {
if (protocol() !== "v1") return []
if (sync().data.config.share === "disabled") return []
return [
sessionCommand({