Compare commits
5 commits
dev
...
v2-capabil
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1adc655cc4 |
||
|
|
b751e26565 |
||
|
|
1612baf254 |
||
|
|
ef8f238c49 |
||
|
|
93e186b5df |
21 changed files with 2412 additions and 2065 deletions
|
|
@ -47,7 +47,7 @@ import { ServerSyncProvider, useServerSync } from "@/context/server-sync"
|
||||||
import { GlobalProvider, useGlobal } from "@/context/global"
|
import { GlobalProvider, useGlobal } from "@/context/global"
|
||||||
import { HighlightsProvider } from "@/context/highlights"
|
import { HighlightsProvider } from "@/context/highlights"
|
||||||
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
|
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 { ModelsProvider } from "@/context/models"
|
||||||
import { NotificationProvider } from "@/context/notification"
|
import { NotificationProvider } from "@/context/notification"
|
||||||
import { PermissionProvider } from "@/context/permission"
|
import { PermissionProvider } from "@/context/permission"
|
||||||
|
|
@ -67,7 +67,8 @@ import { legacySessionHref, legacySessionServer, requireServerKey, sessionHref }
|
||||||
import { createSessionLineage } from "@/pages/session/session-lineage"
|
import { createSessionLineage } from "@/pages/session/session-lineage"
|
||||||
|
|
||||||
import { SessionPage, SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session"
|
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"))
|
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>
|
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 {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
__OPENCODE__?: {
|
__OPENCODE__?: {
|
||||||
|
|
@ -557,15 +596,17 @@ export function AppInterface(props: {
|
||||||
component={props.router ?? Router}
|
component={props.router ?? Router}
|
||||||
root={(routerProps) => (
|
root={(routerProps) => (
|
||||||
<TabsProvider>
|
<TabsProvider>
|
||||||
<PermissionProvider>
|
<LayoutCompatibility>
|
||||||
<NotificationProvider>
|
<PermissionProvider>
|
||||||
<ServerShell>
|
<NotificationProvider>
|
||||||
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
|
<ServerShell>
|
||||||
<NewAppLayout serverScoped={props.serverScoped}>{routerProps.children}</NewAppLayout>
|
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
|
||||||
</Show>
|
<NewAppLayout serverScoped={props.serverScoped}>{routerProps.children}</NewAppLayout>
|
||||||
</ServerShell>
|
</Show>
|
||||||
</NotificationProvider>
|
</ServerShell>
|
||||||
</PermissionProvider>
|
</NotificationProvider>
|
||||||
|
</PermissionProvider>
|
||||||
|
</LayoutCompatibility>
|
||||||
</TabsProvider>
|
</TabsProvider>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -131,6 +131,7 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
|
||||||
|
|
||||||
const saveMutation = useMutation(() => ({
|
const saveMutation = useMutation(() => ({
|
||||||
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => {
|
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 disabledProviders = serverSync().data.config.disabled_providers ?? []
|
||||||
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
|
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,47 @@ export const ServerRowMenu: Component<{
|
||||||
}> = (props) => {
|
}> = (props) => {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const key = ServerConnection.key(props.server)
|
const key = ServerConnection.key(props.server)
|
||||||
const builtin = ServerConnection.builtin(props.server)
|
return (
|
||||||
const isDefault = () => props.controller.defaultKey() === key
|
<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 (
|
return (
|
||||||
<MenuV2 gutter={6} modal={false} placement="bottom-end" open={props.open} onOpenChange={props.onOpenChange}>
|
<MenuV2 gutter={6} modal={false} placement="bottom-end" open={props.open} onOpenChange={props.onOpenChange}>
|
||||||
<MenuV2.Trigger
|
<MenuV2.Trigger
|
||||||
|
|
@ -25,31 +63,30 @@ export const ServerRowMenu: Component<{
|
||||||
variant="ghost-muted"
|
variant="ghost-muted"
|
||||||
size="small"
|
size="small"
|
||||||
icon={<IconV2 name="outline-dots" />}
|
icon={<IconV2 name="outline-dots" />}
|
||||||
aria-label={language.t("common.moreOptions")}
|
aria-label={props.labels.more}
|
||||||
/>
|
/>
|
||||||
<MenuV2.Portal>
|
<MenuV2.Portal>
|
||||||
<MenuV2.Content>
|
<MenuV2.Content>
|
||||||
<MenuV2.Group>
|
<MenuV2.Group>
|
||||||
<MenuV2.GroupLabel>{language.t("settings.section.server")}</MenuV2.GroupLabel>
|
<MenuV2.GroupLabel>{props.labels.server}</MenuV2.GroupLabel>
|
||||||
<MenuV2.Item
|
<MenuV2.Item
|
||||||
disabled={builtin || props.server.type !== "http"}
|
disabled={builtin() || !httpServer()}
|
||||||
onSelect={() => props.onEdit(props.server as ServerConnection.Http)}
|
onSelect={() => {
|
||||||
|
const server = httpServer()
|
||||||
|
if (server) props.onEdit(server)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{language.t("dialog.server.menu.edit")}
|
{props.labels.edit}
|
||||||
</MenuV2.Item>
|
</MenuV2.Item>
|
||||||
<Show when={props.controller.canDefault() && !isDefault()}>
|
<Show when={props.canDefault && !props.isDefault}>
|
||||||
<MenuV2.Item onSelect={() => props.controller.setDefault(key)}>
|
<MenuV2.Item onSelect={props.onSetDefault}>{props.labels.default}</MenuV2.Item>
|
||||||
{language.t("dialog.server.menu.default")}
|
|
||||||
</MenuV2.Item>
|
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={props.controller.canDefault() && isDefault()}>
|
<Show when={props.canDefault && props.isDefault}>
|
||||||
<MenuV2.Item onSelect={() => props.controller.setDefault(null)}>
|
<MenuV2.Item onSelect={props.onRemoveDefault}>{props.labels.defaultRemove}</MenuV2.Item>
|
||||||
{language.t("dialog.server.menu.defaultRemove")}
|
|
||||||
</MenuV2.Item>
|
|
||||||
</Show>
|
</Show>
|
||||||
<MenuV2.Separator />
|
<MenuV2.Separator />
|
||||||
<MenuV2.Item disabled={builtin} onSelect={() => props.controller.handleRemove(key)}>
|
<MenuV2.Item disabled={builtin()} onSelect={props.onRemove}>
|
||||||
{language.t("dialog.server.menu.delete")}
|
{props.labels.delete}
|
||||||
</MenuV2.Item>
|
</MenuV2.Item>
|
||||||
</MenuV2.Group>
|
</MenuV2.Group>
|
||||||
</MenuV2.Content>
|
</MenuV2.Content>
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import { useLanguage } from "@/context/language"
|
||||||
import { usePermission } from "@/context/permission"
|
import { usePermission } from "@/context/permission"
|
||||||
import { usePlatform, type DisplayBackend } from "@/context/platform"
|
import { usePlatform, type DisplayBackend } from "@/context/platform"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
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 { useUpdaterAction } from "./updater-action"
|
||||||
import {
|
import {
|
||||||
monoDefault,
|
monoDefault,
|
||||||
|
|
@ -125,10 +125,12 @@ export const SettingsGeneral: Component = () => {
|
||||||
|
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const serverSdk = useServerSDK()
|
const serverSdk = useServerSDK()
|
||||||
|
const protocol = useServerProtocol()
|
||||||
|
|
||||||
const [shells] = createResource(
|
const [shells] = createResource(
|
||||||
() =>
|
() => (protocol() === "v1" ? serverSdk() : undefined),
|
||||||
serverSdk()
|
(sdk) =>
|
||||||
|
sdk
|
||||||
.client.pty.shells()
|
.client.pty.shells()
|
||||||
.then((res) => res.data ?? [])
|
.then((res) => res.data ?? [])
|
||||||
.catch(() => [] as ShellOption[]),
|
.catch(() => [] as ShellOption[]),
|
||||||
|
|
@ -322,27 +324,29 @@ export const SettingsGeneral: Component = () => {
|
||||||
</div>
|
</div>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
|
|
||||||
<SettingsRow
|
<Show when={protocol() === "v1"}>
|
||||||
title={language.t("settings.general.row.shell.title")}
|
<SettingsRow
|
||||||
description={language.t("settings.general.row.shell.description")}
|
title={language.t("settings.general.row.shell.title")}
|
||||||
>
|
description={language.t("settings.general.row.shell.description")}
|
||||||
<Select
|
>
|
||||||
data-action="settings-shell"
|
<Select
|
||||||
options={shellOptions()}
|
data-action="settings-shell"
|
||||||
current={shellOptions().find((o) => o.value === currentShell()) ?? autoOption}
|
options={shellOptions()}
|
||||||
value={(o) => o.id}
|
current={shellOptions().find((o) => o.value === currentShell()) ?? autoOption}
|
||||||
label={(o) => o.label}
|
value={(o) => o.id}
|
||||||
onSelect={(option) => {
|
label={(o) => o.label}
|
||||||
if (!option) return
|
onSelect={(option) => {
|
||||||
if (option.value === currentShell()) return
|
if (!option) return
|
||||||
serverSync().updateConfig({ shell: option.value })
|
if (option.value === currentShell()) return
|
||||||
}}
|
serverSync().updateConfig({ shell: option.value })
|
||||||
variant="secondary"
|
}}
|
||||||
size="small"
|
variant="secondary"
|
||||||
triggerVariant="settings"
|
size="small"
|
||||||
triggerStyle={{ "min-width": "180px" }}
|
triggerVariant="settings"
|
||||||
/>
|
triggerStyle={{ "min-width": "180px" }}
|
||||||
</SettingsRow>
|
/>
|
||||||
|
</SettingsRow>
|
||||||
|
</Show>
|
||||||
|
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
title={language.t("settings.general.row.reasoningSummaries.title")}
|
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 { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||||
import { createMemo, type Component, For, Show } from "solid-js"
|
import { createMemo, type Component, For, Show } from "solid-js"
|
||||||
import { useLanguage } from "@/context/language"
|
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 { useServerSync } from "@/context/server-sync"
|
||||||
import { DialogConnectProvider, useProviderConnectController } from "./dialog-connect-provider"
|
import { DialogConnectProvider, useProviderConnectController } from "./dialog-connect-provider"
|
||||||
import { DialogCustomProvider } from "./dialog-custom-provider"
|
import { DialogCustomProvider } from "./dialog-custom-provider"
|
||||||
|
|
@ -39,6 +39,7 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const serverSDK = useServerSDK()
|
const serverSDK = useServerSDK()
|
||||||
|
const protocol = useServerProtocol()
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const providers = useProviders()
|
const providers = useProviders()
|
||||||
const providerConnect = useProviderConnectController({ onBack: props.onBack })
|
const providerConnect = useProviderConnectController({ onBack: props.onBack })
|
||||||
|
|
@ -83,7 +84,8 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
|
||||||
return language.t("settings.providers.tag.other")
|
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
|
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) => {
|
const disableProvider = async (providerID: string, name: string) => {
|
||||||
|
if (protocol() !== "v1") return
|
||||||
const before = serverSync().data.config.disabled_providers ?? []
|
const before = serverSync().data.config.disabled_providers ?? []
|
||||||
const next = before.includes(providerID) ? before : [...before, providerID]
|
const next = before.includes(providerID) ? before : [...before, providerID]
|
||||||
serverSync().set("config", "disabled_providers", next)
|
serverSync().set("config", "disabled_providers", next)
|
||||||
|
|
@ -218,31 +221,33 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
|
|
||||||
<div
|
<Show when={protocol() === "v1"}>
|
||||||
class="flex items-center justify-between gap-4 min-h-16 border-b border-border-weak-base last:border-none flex-wrap py-3"
|
<div
|
||||||
data-component="custom-provider-section"
|
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} />)
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{language.t("common.connect")}
|
<div class="flex flex-col min-w-0">
|
||||||
</Button>
|
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||||
</div>
|
<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>
|
</SettingsList>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import { useLanguage } from "@/context/language"
|
||||||
import { usePermission } from "@/context/permission"
|
import { usePermission } from "@/context/permission"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
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 { useUpdaterAction } from "../updater-action"
|
||||||
import {
|
import {
|
||||||
monoDefault,
|
monoDefault,
|
||||||
|
|
@ -92,6 +92,7 @@ export const SettingsGeneralV2: Component<{
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const serverSdk = useServerSDK()
|
const serverSdk = useServerSDK()
|
||||||
|
const protocol = useServerProtocol()
|
||||||
const mobile = createMediaQuery("(max-width: 767px)")
|
const mobile = createMediaQuery("(max-width: 767px)")
|
||||||
|
|
||||||
const updater = useUpdaterAction()
|
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 themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
|
||||||
|
|
||||||
const [shells] = createResource(
|
const [shells] = createResource(
|
||||||
() =>
|
() => (protocol() === "v1" ? serverSdk() : undefined),
|
||||||
serverSdk()
|
(sdk) =>
|
||||||
|
sdk
|
||||||
.client.pty.shells()
|
.client.pty.shells()
|
||||||
.then((res) => res.data ?? [])
|
.then((res) => res.data ?? [])
|
||||||
.catch(() => [] as ShellOption[]),
|
.catch(() => [] as ShellOption[]),
|
||||||
|
|
@ -281,26 +283,28 @@ export const SettingsGeneralV2: Component<{
|
||||||
</div>
|
</div>
|
||||||
</SettingsRowV2>
|
</SettingsRowV2>
|
||||||
|
|
||||||
<SettingsRowV2
|
<Show when={protocol() === "v1"}>
|
||||||
title={language.t("settings.general.row.shell.title")}
|
<SettingsRowV2
|
||||||
description={language.t("settings.general.row.shell.description")}
|
title={language.t("settings.general.row.shell.title")}
|
||||||
>
|
description={language.t("settings.general.row.shell.description")}
|
||||||
<SelectV2
|
>
|
||||||
appearance="inline"
|
<SelectV2
|
||||||
data-action="settings-shell"
|
appearance="inline"
|
||||||
options={shellOptions()}
|
data-action="settings-shell"
|
||||||
current={shellOptions().find((o) => o.value === currentShell()) ?? autoOption}
|
options={shellOptions()}
|
||||||
placement="bottom-end"
|
current={shellOptions().find((o) => o.value === currentShell()) ?? autoOption}
|
||||||
gutter={6}
|
placement="bottom-end"
|
||||||
value={(o) => o.id}
|
gutter={6}
|
||||||
label={(o) => o.label}
|
value={(o) => o.id}
|
||||||
onSelect={(option) => {
|
label={(o) => o.label}
|
||||||
if (!option) return
|
onSelect={(option) => {
|
||||||
if (option.value === currentShell()) return
|
if (!option) return
|
||||||
serverSync().updateConfig({ shell: option.value })
|
if (option.value === currentShell()) return
|
||||||
}}
|
serverSync().updateConfig({ shell: option.value })
|
||||||
/>
|
}}
|
||||||
</SettingsRowV2>
|
/>
|
||||||
|
</SettingsRowV2>
|
||||||
|
</Show>
|
||||||
|
|
||||||
<SettingsRowV2
|
<SettingsRowV2
|
||||||
title={language.t("settings.general.row.reasoningSummaries.title")}
|
title={language.t("settings.general.row.reasoningSummaries.title")}
|
||||||
|
|
@ -692,7 +696,9 @@ export const SettingsGeneralV2: Component<{
|
||||||
|
|
||||||
<div class="settings-v2-tab-body">
|
<div class="settings-v2-tab-body">
|
||||||
<Show when={settings.general.layoutTransitionAvailable()}>
|
<Show when={settings.general.layoutTransitionAvailable()}>
|
||||||
<InterfaceSection />
|
<Show when={!settings.general.newLayoutRequired()}>
|
||||||
|
<InterfaceSection />
|
||||||
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={settings.general.newInterfaceNoticeVisible()}>
|
<Show when={settings.general.newInterfaceNoticeVisible()}>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { showToast } from "@/utils/toast"
|
||||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||||
import { createMemo, type Component, For, Show } from "solid-js"
|
import { createMemo, type Component, For, Show } from "solid-js"
|
||||||
import { useLanguage } from "@/context/language"
|
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 { useServerSync } from "@/context/server-sync"
|
||||||
import { DialogConnectProvider, useProviderConnectController } from "../dialog-connect-provider"
|
import { DialogConnectProvider, useProviderConnectController } from "../dialog-connect-provider"
|
||||||
import { DialogCustomProvider } from "../dialog-custom-provider"
|
import { DialogCustomProvider } from "../dialog-custom-provider"
|
||||||
|
|
@ -33,6 +33,7 @@ export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) =
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const serverSdk = useServerSDK()
|
const serverSdk = useServerSDK()
|
||||||
|
const protocol = useServerProtocol()
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const providers = useProviders()
|
const providers = useProviders()
|
||||||
const providerConnect = useProviderConnectController({ onBack: props.onBack })
|
const providerConnect = useProviderConnectController({ onBack: props.onBack })
|
||||||
|
|
@ -77,7 +78,8 @@ export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) =
|
||||||
return language.t("settings.providers.tag.other")
|
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
|
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) => {
|
const disableProvider = async (providerID: string, name: string) => {
|
||||||
|
if (protocol() !== "v1") return
|
||||||
const before = serverSync().data.config.disabled_providers ?? []
|
const before = serverSync().data.config.disabled_providers ?? []
|
||||||
const next = before.includes(providerID) ? before : [...before, providerID]
|
const next = before.includes(providerID) ? before : [...before, providerID]
|
||||||
serverSync().set("config", "disabled_providers", next)
|
serverSync().set("config", "disabled_providers", next)
|
||||||
|
|
@ -218,33 +221,35 @@ export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) =
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
|
|
||||||
<div class="settings-v2-provider-row" data-component="custom-provider-section">
|
<Show when={protocol() === "v1"}>
|
||||||
<div class="settings-v2-provider-lead">
|
<div class="settings-v2-provider-row" data-component="custom-provider-section">
|
||||||
<ProviderIcon
|
<div class="settings-v2-provider-lead">
|
||||||
id="synthetic"
|
<ProviderIcon
|
||||||
width={PROVIDER_ICON_SIZE}
|
id="synthetic"
|
||||||
height={PROVIDER_ICON_SIZE}
|
width={PROVIDER_ICON_SIZE}
|
||||||
class="settings-v2-provider-icon shrink-0"
|
height={PROVIDER_ICON_SIZE}
|
||||||
/>
|
class="settings-v2-provider-icon shrink-0"
|
||||||
<div class="settings-v2-provider-copy">
|
/>
|
||||||
<div class="settings-v2-provider-main">
|
<div class="settings-v2-provider-copy">
|
||||||
<span class="settings-v2-provider-name">{language.t("provider.custom.title")}</span>
|
<div class="settings-v2-provider-main">
|
||||||
<Tag>{language.t("settings.providers.tag.custom")}</Tag>
|
<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>
|
</div>
|
||||||
<p class="settings-v2-provider-description">{language.t("settings.providers.custom.description")}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<ButtonV2
|
||||||
|
size="normal"
|
||||||
|
variant="neutral"
|
||||||
|
icon="plus"
|
||||||
|
onClick={() => {
|
||||||
|
dialog.show(() => <DialogCustomProvider onBack={dialog.close} />)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{language.t("common.connect")}
|
||||||
|
</ButtonV2>
|
||||||
</div>
|
</div>
|
||||||
<ButtonV2
|
</Show>
|
||||||
size="normal"
|
|
||||||
variant="neutral"
|
|
||||||
icon="plus"
|
|
||||||
onClick={() => {
|
|
||||||
dialog.show(() => <DialogCustomProvider onBack={dialog.close} />)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{language.t("common.connect")}
|
|
||||||
</ButtonV2>
|
|
||||||
</div>
|
|
||||||
</SettingsListV2>
|
</SettingsListV2>
|
||||||
|
|
||||||
<button type="button" class="settings-v2-providers-view-all" onClick={() => connect()}>
|
<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 { useGlobal } from "@/context/global"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
import { useMcpToggle } from "@/context/mcp"
|
import { useMcpToggle } from "@/context/mcp"
|
||||||
|
import { useServerProtocol } from "@/context/server-sdk"
|
||||||
|
|
||||||
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
|
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
|
||||||
const parts = value.split(file)
|
const parts = value.split(file)
|
||||||
|
|
@ -257,6 +258,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
|
const protocol = useServerProtocol()
|
||||||
|
|
||||||
const fail = (err: unknown) => {
|
const fail = (err: unknown) => {
|
||||||
showToast({
|
showToast({
|
||||||
|
|
@ -315,10 +317,12 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||||
{lspCount() > 0 ? `${lspCount()} ` : ""}
|
{lspCount() > 0 ? `${lspCount()} ` : ""}
|
||||||
{language.t("status.popover.tab.lsp")}
|
{language.t("status.popover.tab.lsp")}
|
||||||
</Tabs.Trigger>
|
</Tabs.Trigger>
|
||||||
<Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular">
|
<Show when={protocol() === "v1"}>
|
||||||
{pluginCount() > 0 ? `${pluginCount()} ` : ""}
|
<Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular">
|
||||||
{language.t("status.popover.tab.plugins")}
|
{pluginCount() > 0 ? `${pluginCount()} ` : ""}
|
||||||
</Tabs.Trigger>
|
{language.t("status.popover.tab.plugins")}
|
||||||
|
</Tabs.Trigger>
|
||||||
|
</Show>
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
|
|
||||||
{!settings.general.newLayoutDesigns() && (
|
{!settings.general.newLayoutDesigns() && (
|
||||||
|
|
@ -478,25 +482,27 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||||
</div>
|
</div>
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
|
|
||||||
<Tabs.Content value="plugins">
|
<Show when={protocol() === "v1"}>
|
||||||
<div class="flex flex-col px-2 pb-2">
|
<Tabs.Content value="plugins">
|
||||||
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
|
<div class="flex flex-col px-2 pb-2">
|
||||||
<Show
|
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
|
||||||
when={plugins().length > 0}
|
<Show
|
||||||
fallback={<div class="text-14-regular text-text-base text-center my-auto">{pluginEmpty()}</div>}
|
when={plugins().length > 0}
|
||||||
>
|
fallback={<div class="text-14-regular text-text-base text-center my-auto">{pluginEmpty()}</div>}
|
||||||
<For each={plugins()}>
|
>
|
||||||
{(plugin) => (
|
<For each={plugins()}>
|
||||||
<div class="flex items-center gap-2 w-full px-2 py-1">
|
{(plugin) => (
|
||||||
<div class="size-1.5 rounded-full shrink-0 bg-icon-success-base" />
|
<div class="flex items-center gap-2 w-full px-2 py-1">
|
||||||
<span class="text-14-regular text-text-base truncate">{plugin}</span>
|
<div class="size-1.5 rounded-full shrink-0 bg-icon-success-base" />
|
||||||
</div>
|
<span class="text-14-regular text-text-base truncate">{plugin}</span>
|
||||||
)}
|
</div>
|
||||||
</For>
|
)}
|
||||||
</Show>
|
</For>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Tabs.Content>
|
||||||
</Tabs.Content>
|
</Show>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
const parts = pathname.split("/").filter(Boolean)
|
||||||
if (parts.length === 0) return { type: "home" }
|
if (parts.length === 0) return { type: "home" }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -211,6 +211,7 @@ function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }
|
||||||
)
|
)
|
||||||
|
|
||||||
function enableConfiguredDirectory(directory: string) {
|
function enableConfiguredDirectory(directory: string) {
|
||||||
|
if (input.sdk.protocolKind() !== "v1") return
|
||||||
if (meta.disposed || !ready()) return
|
if (meta.disposed || !ready()) return
|
||||||
const [childStore] = input.sync.child(directory)
|
const [childStore] = input.sync.child(directory)
|
||||||
if (childStore.config.permission !== "allow") return
|
if (childStore.config.permission !== "allow") return
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import type { Event } from "@opencode-ai/sdk/v2/client"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
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 { createApiForServer, createSdkForServer, type ServerApi } from "@/utils/server"
|
||||||
import { useLanguage } from "./language"
|
import { useLanguage } from "./language"
|
||||||
import { usePlatform } from "./platform"
|
import { usePlatform } from "./platform"
|
||||||
|
|
@ -169,6 +169,7 @@ type ServerSDKBase = {
|
||||||
server: ServerConnection.Any
|
server: ServerConnection.Any
|
||||||
scope: ServerScope
|
scope: ServerScope
|
||||||
protocol: Promise<ServerProtocol>
|
protocol: Promise<ServerProtocol>
|
||||||
|
protocolKind: Accessor<ServerProtocol | undefined>
|
||||||
url: string
|
url: string
|
||||||
client: ReturnType<typeof createSdkForServer>
|
client: ReturnType<typeof createSdkForServer>
|
||||||
api: CompatibleApi
|
api: CompatibleApi
|
||||||
|
|
@ -205,6 +206,10 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||||
server: server.http,
|
server: server.http,
|
||||||
})
|
})
|
||||||
const protocol = detectServerProtocol(server.http, platform.fetch ?? globalThis.fetch)
|
const protocol = detectServerProtocol(server.http, platform.fetch ?? globalThis.fetch)
|
||||||
|
const [protocolKind] = createResource(
|
||||||
|
() => protocol,
|
||||||
|
(value) => value,
|
||||||
|
)
|
||||||
const emitter = createGlobalEmitter<{
|
const emitter = createGlobalEmitter<{
|
||||||
[key: string]: ServerEvent
|
[key: string]: ServerEvent
|
||||||
}>()
|
}>()
|
||||||
|
|
@ -347,6 +352,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||||
server,
|
server,
|
||||||
scope,
|
scope,
|
||||||
protocol,
|
protocol,
|
||||||
|
protocolKind,
|
||||||
url: server.http.url,
|
url: server.http.url,
|
||||||
client: sdk,
|
client: sdk,
|
||||||
api,
|
api,
|
||||||
|
|
@ -394,6 +400,11 @@ export const { use: useServerSDK, provider: ServerSDKProvider } = createSimpleCo
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export function useServerProtocol() {
|
||||||
|
const serverSDK = useServerSDK()
|
||||||
|
return createMemo(() => serverSDK().protocolKind())
|
||||||
|
}
|
||||||
|
|
||||||
type SDKEventMap = {
|
type SDKEventMap = {
|
||||||
[key in Event["type"]]: Extract<ServerEvent, { type: key }>
|
[key in Event["type"]]: Extract<ServerEvent, { type: key }>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -230,6 +230,7 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||||
migrationApplied: false,
|
migrationApplied: false,
|
||||||
previous: undefined as string | undefined,
|
previous: undefined as string | undefined,
|
||||||
})
|
})
|
||||||
|
const [newLayoutRequired, setNewLayoutRequired] = createSignal(false)
|
||||||
const showFileTree = withFallback(() => store.general?.showFileTree, defaultSettings.general.showFileTree)
|
const showFileTree = withFallback(() => store.general?.showFileTree, defaultSettings.general.showFileTree)
|
||||||
const showSearch = withFallback(() => store.general?.showSearch, defaultSettings.general.showSearch)
|
const showSearch = withFallback(() => store.general?.showSearch, defaultSettings.general.showSearch)
|
||||||
const showStatus = withFallback(() => store.general?.showStatus, defaultSettings.general.showStatus)
|
const showStatus = withFallback(() => store.general?.showStatus, defaultSettings.general.showStatus)
|
||||||
|
|
@ -250,7 +251,7 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||||
const layoutTransition = createMemo(() =>
|
const layoutTransition = createMemo(() =>
|
||||||
layoutTransitionState(!!sunset, layoutTransitionEligible(), oldInterfaceRetired(), newInterfaceNoticeDismissed()),
|
layoutTransitionState(!!sunset, layoutTransitionEligible(), oldInterfaceRetired(), newInterfaceNoticeDismissed()),
|
||||||
)
|
)
|
||||||
const newLayoutDesigns = createMemo(() => {
|
const preferredNewLayout = createMemo(() => {
|
||||||
if (layoutUpgrade()) return true
|
if (layoutUpgrade()) return true
|
||||||
if (!ready() && !oldInterfaceRetired()) return legacyNewLayoutDesignsDefault
|
if (!ready() && !oldInterfaceRetired()) return legacyNewLayoutDesignsDefault
|
||||||
if (!layoutTransitionClassified()) {
|
if (!layoutTransitionClassified()) {
|
||||||
|
|
@ -266,6 +267,7 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||||
layoutTransitionEligible() ? legacyNewLayoutDesignsDefault : newLayoutDesignsDefault,
|
layoutTransitionEligible() ? legacyNewLayoutDesignsDefault : newLayoutDesignsDefault,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
const newLayoutDesigns = createMemo(() => newLayoutRequired() || preferredNewLayout())
|
||||||
const visible = (preference: () => boolean) => createMemo(() => !newLayoutDesigns() || preference())
|
const visible = (preference: () => boolean) => createMemo(() => !newLayoutDesigns() || preference())
|
||||||
|
|
||||||
if (sunset && !oldInterfaceRetired()) {
|
if (sunset && !oldInterfaceRetired()) {
|
||||||
|
|
@ -404,9 +406,12 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||||
setStore("general", "mobileTitlebarPosition", value)
|
setStore("general", "mobileTitlebarPosition", value)
|
||||||
},
|
},
|
||||||
newLayoutDesigns,
|
newLayoutDesigns,
|
||||||
|
newLayoutRequired,
|
||||||
|
setNewLayoutRequired,
|
||||||
setNewLayoutDesigns(value: boolean) {
|
setNewLayoutDesigns(value: boolean) {
|
||||||
|
if (newLayoutRequired() && !value) return
|
||||||
const next = oldInterfaceRetired() ? true : value
|
const next = oldInterfaceRetired() ? true : value
|
||||||
if (newLayoutDesigns() === next) return
|
if (preferredNewLayout() === next) return
|
||||||
setStore("general", "newLayoutDesigns", next)
|
setStore("general", "newLayoutDesigns", next)
|
||||||
if (typeof window !== "undefined") setTimeout(() => window.location.reload())
|
if (typeof window !== "undefined") setTimeout(() => window.location.reload())
|
||||||
},
|
},
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
265
packages/app/src/pages/home/home-projects-controller.tsx
Normal file
265
packages/app/src/pages/home/home-projects-controller.tsx
Normal file
|
|
@ -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>
|
||||||
548
packages/app/src/pages/home/home-projects-view.tsx
Normal file
548
packages/app/src/pages/home/home-projects-view.tsx
Normal file
|
|
@ -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)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
578
packages/app/src/pages/home/home-sessions-controller.tsx
Normal file
578
packages/app/src/pages/home/home-sessions-controller.tsx
Normal file
|
|
@ -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),
|
||||||
|
})
|
||||||
|
}
|
||||||
491
packages/app/src/pages/home/home-sessions-view.tsx
Normal file
491
packages/app/src/pages/home/home-sessions-view.tsx
Normal file
|
|
@ -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
packages/app/src/pages/home/legacy-home.tsx
Normal file
142
packages/app/src/pages/home/legacy-home.tsx
Normal 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.directory,
|
||||||
() => props.sessionId,
|
() => 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 = () => (
|
const projectAvatar = () => (
|
||||||
<ProjectAvatar
|
<ProjectAvatar
|
||||||
fallback={displayName(props.project ?? { worktree: props.directory })}
|
fallback={displayName(props.project ?? { worktree: props.directory })}
|
||||||
src={getProjectAvatarSource(props.project?.id, props.project?.icon)}
|
src={getProjectAvatarSource(props.project?.id, props.project?.icon)}
|
||||||
variant={getProjectAvatarVariant(props.project?.icon?.color)}
|
variant={getProjectAvatarVariant(props.project?.icon?.color)}
|
||||||
unread={state.unread()}
|
unread={props.unread}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
return (
|
return (
|
||||||
<Show when={state.loading()} fallback={projectAvatar()}>
|
<Show when={props.loading} fallback={projectAvatar()}>
|
||||||
<span class="relative block size-4 shrink-0">
|
<span class="relative block size-4 shrink-0">
|
||||||
<SessionProgressIndicatorV2
|
<SessionProgressIndicatorV2
|
||||||
class={`absolute inset-0 ${props.revealProjectOnHover === false ? "" : "group-hover:invisible"}`}
|
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 { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useSessionKey } from "@/pages/session/session-layout"
|
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 { usePlatform } from "@/context/platform"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
import { useTabs } from "@/context/tabs"
|
import { useTabs } from "@/context/tabs"
|
||||||
|
|
@ -291,7 +291,8 @@ export function MessageTimeline(props: {
|
||||||
const titleValue = createMemo(() => info()?.title)
|
const titleValue = createMemo(() => info()?.title)
|
||||||
const titleLabel = createMemo(() => sessionTitle(titleValue()))
|
const titleLabel = createMemo(() => sessionTitle(titleValue()))
|
||||||
const shareUrl = createMemo(() => info()?.share?.url)
|
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 parentID = createMemo(() => info()?.parentID)
|
||||||
const parent = createMemo(() => {
|
const parent = createMemo(() => {
|
||||||
const id = parentID()
|
const id = parentID()
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import { extractPromptFromParts } from "@/utils/prompt"
|
||||||
import { UserMessage } from "@opencode-ai/sdk/v2"
|
import { UserMessage } from "@opencode-ai/sdk/v2"
|
||||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||||
import { createSessionOwnership } from "./session-ownership"
|
import { createSessionOwnership } from "./session-ownership"
|
||||||
|
import { useServerProtocol } from "@/context/server-sdk"
|
||||||
|
|
||||||
export type SessionCommandContext = {
|
export type SessionCommandContext = {
|
||||||
navigateMessageByOffset: (offset: number) => void
|
navigateMessageByOffset: (offset: number) => void
|
||||||
|
|
@ -45,6 +46,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||||
const prompt = usePrompt()
|
const prompt = usePrompt()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
|
const protocol = useServerProtocol()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const terminal = useTerminal()
|
const terminal = useTerminal()
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
|
|
@ -184,6 +186,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const share = async () => {
|
const share = async () => {
|
||||||
|
if (protocol() !== "v1") return
|
||||||
const sessionID = params.id
|
const sessionID = params.id
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
|
|
||||||
|
|
@ -210,6 +213,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const unshare = async () => {
|
const unshare = async () => {
|
||||||
|
if (protocol() !== "v1") return
|
||||||
const sessionID = params.id
|
const sessionID = params.id
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
|
|
||||||
|
|
@ -390,6 +394,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const shareCmds = () => {
|
const shareCmds = () => {
|
||||||
|
if (protocol() !== "v1") return []
|
||||||
if (sync().data.config.share === "disabled") return []
|
if (sync().data.config.share === "disabled") return []
|
||||||
return [
|
return [
|
||||||
sessionCommand({
|
sessionCommand({
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue