feat(app): add desktop v2 home, session entry, and titlebar (#28442)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
This commit is contained in:
parent
6602341c0d
commit
b207e32249
26 changed files with 2052 additions and 620 deletions
|
|
@ -12,7 +12,7 @@ import { type LocalProject, getAvatarColors } from "@/context/layout"
|
|||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { Avatar } from "@opencode-ai/ui/avatar"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { getProjectAvatarSource } from "@/pages/layout/sidebar-items"
|
||||
import { getProjectAvatarSource } from "@/pages/layout/helpers"
|
||||
|
||||
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,17 @@
|
|||
import { useFilteredList } from "@opencode-ai/ui/hooks"
|
||||
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
||||
import { createEffect, on, Component, Show, onCleanup, createMemo, createSignal, createResource } from "solid-js"
|
||||
import {
|
||||
createEffect,
|
||||
on,
|
||||
Component,
|
||||
Show,
|
||||
onCleanup,
|
||||
createMemo,
|
||||
createSignal,
|
||||
createResource,
|
||||
Switch,
|
||||
Match,
|
||||
} from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
|
||||
|
|
@ -57,11 +68,14 @@ import { ImagePreview } from "@opencode-ai/ui/image-preview"
|
|||
import { useQueries } from "@tanstack/solid-query"
|
||||
import { useQueryOptions } from "@/context/global-sync"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
|
||||
interface PromptInputProps {
|
||||
class?: string
|
||||
variant?: "dock" | "new-session"
|
||||
ref?: (el: HTMLDivElement) => void
|
||||
newSessionWorktree?: string
|
||||
onNewSessionWorktreeChange?: (worktree: string) => void
|
||||
onNewSessionWorktreeReset?: () => void
|
||||
edit?: { id: string; prompt: Prompt; context: FollowupDraft["context"] }
|
||||
onEditLoaded?: () => void
|
||||
|
|
@ -99,6 +113,9 @@ const EXAMPLES = [
|
|||
"prompt.example.25",
|
||||
] as const
|
||||
|
||||
const MAIN_WORKTREE = "main"
|
||||
const CREATE_WORKTREE = "create"
|
||||
|
||||
export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const sdk = useSDK()
|
||||
const queryOptions = useQueryOptions()
|
||||
|
|
@ -1055,6 +1072,21 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
readClipboardImage: platform.readClipboardImage,
|
||||
})
|
||||
|
||||
const fileAttachmentInput = () => (
|
||||
<input
|
||||
ref={(el) => (fileInputRef = el)}
|
||||
type="file"
|
||||
multiple
|
||||
accept={ACCEPTED_FILE_TYPES.join(",")}
|
||||
class="hidden"
|
||||
onChange={(e) => {
|
||||
const list = e.currentTarget.files
|
||||
if (list) void addAttachments(Array.from(list))
|
||||
e.currentTarget.value = ""
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
const variants = createMemo(() => ["default", ...local.model.variant.list()])
|
||||
const accepting = createMemo(() => {
|
||||
const id = params.id
|
||||
|
|
@ -1266,8 +1298,99 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
(p) => p,
|
||||
)
|
||||
|
||||
const designPlaceholder = () => {
|
||||
if (store.mode === "shell") return placeholder()
|
||||
return "Ask anything, / for commands, @ for context..."
|
||||
}
|
||||
|
||||
const modelControl = () => (
|
||||
<Show when={!providersLoading()}>
|
||||
<Show
|
||||
when={providers.paid().length > 0}
|
||||
fallback={
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
gutter={4}
|
||||
title={language.t("command.model.choose")}
|
||||
keybind={command.keybind("model.choose")}
|
||||
>
|
||||
<Button
|
||||
data-action="prompt-model"
|
||||
as="div"
|
||||
variant="ghost"
|
||||
size="normal"
|
||||
class="min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-4 text-v2-text-text-faint group"
|
||||
style={control()}
|
||||
onClick={() => {
|
||||
void import("@/components/dialog-select-model-unpaid").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectModelUnpaid model={local.model} />)
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Show when={local.model.current()?.provider?.id}>
|
||||
<ProviderIcon
|
||||
id={local.model.current()?.provider?.id ?? ""}
|
||||
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
||||
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
||||
/>
|
||||
</Show>
|
||||
<span class="truncate">{local.model.current()?.name ?? language.t("dialog.model.select.title")}</span>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
}
|
||||
>
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
gutter={4}
|
||||
title={language.t("command.model.choose")}
|
||||
keybind={command.keybind("model.choose")}
|
||||
>
|
||||
<ModelSelectorPopover
|
||||
model={local.model}
|
||||
triggerAs={Button}
|
||||
triggerProps={{
|
||||
variant: "ghost",
|
||||
size: "normal",
|
||||
style: control(),
|
||||
class:
|
||||
"min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-4 text-v2-text-text-faint group",
|
||||
"data-action": "prompt-model",
|
||||
}}
|
||||
onClose={restoreFocus}
|
||||
>
|
||||
<Show when={local.model.current()?.provider?.id}>
|
||||
<ProviderIcon
|
||||
id={local.model.current()?.provider?.id ?? ""}
|
||||
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
||||
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
||||
/>
|
||||
</Show>
|
||||
<span class="truncate">{local.model.current()?.name ?? language.t("dialog.model.select.title")}</span>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
</ModelSelectorPopover>
|
||||
</TooltipKeybind>
|
||||
</Show>
|
||||
</Show>
|
||||
)
|
||||
|
||||
const newSession = () => props.variant === "new-session"
|
||||
const worktrees = createMemo(() => [MAIN_WORKTREE, ...(sync.project?.sandboxes ?? []), CREATE_WORKTREE])
|
||||
const currentWorktree = createMemo(() => {
|
||||
if (worktrees().includes(props.newSessionWorktree ?? MAIN_WORKTREE))
|
||||
return props.newSessionWorktree ?? MAIN_WORKTREE
|
||||
return MAIN_WORKTREE
|
||||
})
|
||||
const worktreeLabel = (value: string) => {
|
||||
if (value === MAIN_WORKTREE) return MAIN_WORKTREE
|
||||
if (value === CREATE_WORKTREE) return language.t("session.new.worktree.create")
|
||||
return getFilename(value)
|
||||
}
|
||||
|
||||
const USE_V2_INPUT = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||
|
||||
return (
|
||||
<div class="relative size-full _max-h-[320px] flex flex-col gap-0">
|
||||
<div class="relative size-full flex flex-col gap-0">
|
||||
{(promptReady(), null)}
|
||||
<PromptPopover
|
||||
popover={store.popover}
|
||||
|
|
@ -1284,121 +1407,138 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
commandKeybind={command.keybind}
|
||||
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
|
||||
/>
|
||||
<DockShellForm
|
||||
onSubmit={handleSubmit}
|
||||
classList={{
|
||||
"group/prompt-input": true,
|
||||
"focus-within:shadow-xs-border": true,
|
||||
"border-icon-info-active border-dashed": store.draggingType !== null,
|
||||
[props.class ?? ""]: !!props.class,
|
||||
}}
|
||||
>
|
||||
<PromptDragOverlay
|
||||
type={store.draggingType}
|
||||
label={language.t(store.draggingType === "@mention" ? "prompt.dropzone.file.label" : "prompt.dropzone.label")}
|
||||
/>
|
||||
<PromptContextItems
|
||||
items={contextItems()}
|
||||
active={(item) => {
|
||||
const active = comments.active()
|
||||
return !!item.commentID && item.commentID === active?.id && item.path === active?.file
|
||||
}}
|
||||
openComment={openComment}
|
||||
remove={(item) => {
|
||||
if (item.commentID) comments.remove(item.path, item.commentID)
|
||||
prompt.context.remove(item.key)
|
||||
}}
|
||||
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
|
||||
/>
|
||||
<PromptImageAttachments
|
||||
attachments={imageAttachments()}
|
||||
onOpen={(attachment) =>
|
||||
dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />)
|
||||
}
|
||||
onRemove={removeAttachment}
|
||||
removeLabel={language.t("prompt.attachment.remove")}
|
||||
/>
|
||||
<div
|
||||
class="relative"
|
||||
onMouseDown={(e) => {
|
||||
const target = e.target
|
||||
if (!(target instanceof HTMLElement)) return
|
||||
if (target.closest('[data-action="prompt-attach"], [data-action="prompt-submit"]')) {
|
||||
return
|
||||
}
|
||||
editorRef?.focus()
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class="relative max-h-[240px] overflow-y-auto no-scrollbar"
|
||||
ref={(el) => (scrollRef = el)}
|
||||
style={{ "scroll-padding-bottom": space }}
|
||||
>
|
||||
<div
|
||||
data-component="prompt-input"
|
||||
ref={(el) => {
|
||||
editorRef = el
|
||||
props.ref?.(el)
|
||||
}}
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
aria-label={placeholder()}
|
||||
contenteditable="true"
|
||||
autocapitalize={store.mode === "normal" ? "sentences" : "off"}
|
||||
autocorrect={store.mode === "normal" ? "on" : "off"}
|
||||
spellcheck={store.mode === "normal"}
|
||||
inputMode="text"
|
||||
// @ts-expect-error
|
||||
autocomplete="off"
|
||||
onInput={handleInput}
|
||||
onPaste={handlePaste}
|
||||
onCompositionStart={handleCompositionStart}
|
||||
onCompositionEnd={handleCompositionEnd}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
classList={{
|
||||
"select-text": true,
|
||||
"w-full pl-3 pr-2 pt-2 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
|
||||
"[&_[data-type=file]]:text-syntax-property": true,
|
||||
"[&_[data-type=agent]]:text-syntax-type": true,
|
||||
"font-mono!": store.mode === "shell",
|
||||
}}
|
||||
style={{ "padding-bottom": space }}
|
||||
/>
|
||||
<div
|
||||
class="absolute top-0 inset-x-0 pl-3 pr-2 pt-2 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate"
|
||||
classList={{ "font-mono!": store.mode === "shell" }}
|
||||
style={{ "padding-bottom": space, display: prompt.dirty() ? "none" : undefined }}
|
||||
>
|
||||
{placeholder()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute inset-x-0 bottom-0"
|
||||
style={{
|
||||
height: space,
|
||||
background:
|
||||
"linear-gradient(to top, var(--surface-raised-stronger-non-alpha) calc(100% - 20px), transparent)",
|
||||
<Switch>
|
||||
<Match when={USE_V2_INPUT}>
|
||||
<DockShellForm
|
||||
data-component={newSession() ? "session-new-composer" : "session-composer"}
|
||||
onSubmit={handleSubmit}
|
||||
classList={{
|
||||
"group/prompt-input min-h-[96px] w-full rounded-xl bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]": true,
|
||||
"border-icon-info-active border-dashed": store.draggingType !== null,
|
||||
[props.class ?? ""]: !!props.class,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div class="pointer-events-none absolute bottom-2 right-2 flex items-center gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept={ACCEPTED_FILE_TYPES.join(",")}
|
||||
class="hidden"
|
||||
onChange={(e) => {
|
||||
const list = e.currentTarget.files
|
||||
if (list) void addAttachments(Array.from(list))
|
||||
e.currentTarget.value = ""
|
||||
}}
|
||||
>
|
||||
<PromptDragOverlay
|
||||
type={store.draggingType}
|
||||
label={language.t(
|
||||
store.draggingType === "@mention" ? "prompt.dropzone.file.label" : "prompt.dropzone.label",
|
||||
)}
|
||||
/>
|
||||
|
||||
<div class="flex items-center gap-1 pointer-events-auto">
|
||||
<PromptContextItems
|
||||
items={contextItems()}
|
||||
active={(item) => {
|
||||
const active = comments.active()
|
||||
return !!item.commentID && item.commentID === active?.id && item.path === active?.file
|
||||
}}
|
||||
openComment={openComment}
|
||||
remove={(item) => {
|
||||
if (item.commentID) comments.remove(item.path, item.commentID)
|
||||
prompt.context.remove(item.key)
|
||||
}}
|
||||
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
|
||||
/>
|
||||
<PromptImageAttachments
|
||||
attachments={imageAttachments()}
|
||||
onOpen={(attachment) =>
|
||||
dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />)
|
||||
}
|
||||
onRemove={removeAttachment}
|
||||
removeLabel={language.t("prompt.attachment.remove")}
|
||||
/>
|
||||
<div
|
||||
class="relative min-h-[52px]"
|
||||
onMouseDown={(e) => {
|
||||
const target = e.target
|
||||
if (!(target instanceof HTMLElement)) return
|
||||
if (target.closest('[data-action="prompt-attach"], [data-action="prompt-submit"]')) return
|
||||
editorRef?.focus()
|
||||
}}
|
||||
>
|
||||
<div class="relative max-h-[180px] overflow-y-auto no-scrollbar" ref={(el) => (scrollRef = el)}>
|
||||
<div
|
||||
data-component="prompt-input"
|
||||
ref={(el) => {
|
||||
editorRef = el
|
||||
props.ref?.(el)
|
||||
}}
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
aria-label={designPlaceholder()}
|
||||
contenteditable="true"
|
||||
autocapitalize={store.mode === "normal" ? "sentences" : "off"}
|
||||
autocorrect={store.mode === "normal" ? "on" : "off"}
|
||||
spellcheck={store.mode === "normal"}
|
||||
inputMode="text"
|
||||
// @ts-expect-error
|
||||
autocomplete="off"
|
||||
onInput={handleInput}
|
||||
onPaste={handlePaste}
|
||||
onCompositionStart={handleCompositionStart}
|
||||
onCompositionEnd={handleCompositionEnd}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
classList={{
|
||||
"select-text": true,
|
||||
"min-h-[52px] w-full px-4 pt-4 pb-2 focus:outline-none whitespace-pre-wrap leading-5 text-[13px] font-[440] text-v2-text-text-faint [font-family:Inter,var(--font-family-sans)]": true,
|
||||
"[&_[data-type=file]]:text-syntax-property": true,
|
||||
"[&_[data-type=agent]]:text-syntax-type": true,
|
||||
"font-mono!": store.mode === "shell",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
data-component={newSession() ? "session-new-design-text" : "session-composer-text"}
|
||||
class="absolute top-0 inset-x-0 px-4 pt-4 pointer-events-none whitespace-nowrap truncate leading-5 text-[13px] font-[440] text-v2-text-text-faint [font-family:Inter,var(--font-family-sans)]"
|
||||
classList={{ "font-mono!": store.mode === "shell", hidden: prompt.dirty() }}
|
||||
>
|
||||
{designPlaceholder()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex h-11 items-center px-2">
|
||||
<div class="flex min-w-0 flex-1 items-center gap-0">
|
||||
{fileAttachmentInput()}
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
title={language.t("prompt.action.attachFile")}
|
||||
keybind={command.keybind("file.attach")}
|
||||
>
|
||||
<IconButton
|
||||
data-action="prompt-attach"
|
||||
type="button"
|
||||
icon="plus"
|
||||
variant="ghost"
|
||||
class="size-7 rounded-md p-[6px] text-v2-icon-icon-muted"
|
||||
style={buttons()}
|
||||
onClick={pick}
|
||||
disabled={store.mode !== "normal"}
|
||||
tabIndex={store.mode === "normal" ? undefined : -1}
|
||||
aria-label={language.t("prompt.action.attachFile")}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
<Show when={newSession()}>
|
||||
<div class="relative">
|
||||
<div class="pointer-events-none absolute left-2 top-1/2 z-10 flex size-4 -translate-y-1/2 items-center justify-center">
|
||||
<Icon name="sliders" size="small" />
|
||||
</div>
|
||||
<Select
|
||||
size="normal"
|
||||
options={worktrees()}
|
||||
current={currentWorktree()}
|
||||
label={worktreeLabel}
|
||||
onSelect={(value) => {
|
||||
if (value) props.onNewSessionWorktreeChange?.(value)
|
||||
restoreFocus()
|
||||
}}
|
||||
class="max-w-[175px] justify-start text-text-base [&_[data-component=icon]]:text-v2-icon-icon-muted"
|
||||
valueClass="truncate pl-5 text-[13px] font-[440] leading-4 text-v2-text-text-faint"
|
||||
triggerStyle={control()}
|
||||
triggerProps={{ "data-action": "prompt-workspace" }}
|
||||
variant="ghost"
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
{modelControl()}
|
||||
</div>
|
||||
<Tooltip placement="top" inactive={!working() && blank()} value={tip()}>
|
||||
<IconButton
|
||||
data-action="prompt-submit"
|
||||
|
|
@ -1407,209 +1547,348 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
tabIndex={store.mode === "normal" ? undefined : -1}
|
||||
icon={stopping() ? "stop" : store.mode === "shell" ? "arrow-undo-down" : "arrow-up"}
|
||||
variant="primary"
|
||||
class="size-8"
|
||||
class="size-7 rounded-md p-[6px] text-v2-icon-icon-muted shadow-[var(--v2-elevation-button-contrast)] disabled:opacity-50"
|
||||
style={{
|
||||
"background-image":
|
||||
"linear-gradient(180deg,var(--v2-alpha-light-20) 0%,var(--v2-alpha-light-0) 100%),linear-gradient(90deg,var(--v2-background-bg-contrast) 0%,var(--v2-background-bg-contrast) 100%)",
|
||||
}}
|
||||
aria-label={stopping() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pointer-events-none absolute bottom-2 left-2">
|
||||
</DockShellForm>
|
||||
</Match>
|
||||
<Match when>
|
||||
<DockShellForm
|
||||
onSubmit={handleSubmit}
|
||||
classList={{
|
||||
"group/prompt-input": true,
|
||||
"focus-within:shadow-xs-border": true,
|
||||
"border-icon-info-active border-dashed": store.draggingType !== null,
|
||||
[props.class ?? ""]: !!props.class,
|
||||
}}
|
||||
>
|
||||
<PromptDragOverlay
|
||||
type={store.draggingType}
|
||||
label={language.t(
|
||||
store.draggingType === "@mention" ? "prompt.dropzone.file.label" : "prompt.dropzone.label",
|
||||
)}
|
||||
/>
|
||||
<PromptContextItems
|
||||
items={contextItems()}
|
||||
active={(item) => {
|
||||
const active = comments.active()
|
||||
return !!item.commentID && item.commentID === active?.id && item.path === active?.file
|
||||
}}
|
||||
openComment={openComment}
|
||||
remove={(item) => {
|
||||
if (item.commentID) comments.remove(item.path, item.commentID)
|
||||
prompt.context.remove(item.key)
|
||||
}}
|
||||
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
|
||||
/>
|
||||
<PromptImageAttachments
|
||||
attachments={imageAttachments()}
|
||||
onOpen={(attachment) =>
|
||||
dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />)
|
||||
}
|
||||
onRemove={removeAttachment}
|
||||
removeLabel={language.t("prompt.attachment.remove")}
|
||||
/>
|
||||
<div
|
||||
aria-hidden={store.mode !== "normal"}
|
||||
class="pointer-events-auto"
|
||||
style={{
|
||||
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
|
||||
class="relative"
|
||||
onMouseDown={(e) => {
|
||||
const target = e.target
|
||||
if (!(target instanceof HTMLElement)) return
|
||||
if (target.closest('[data-action="prompt-attach"], [data-action="prompt-submit"]')) {
|
||||
return
|
||||
}
|
||||
editorRef?.focus()
|
||||
}}
|
||||
>
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
title={language.t("prompt.action.attachFile")}
|
||||
keybind={command.keybind("file.attach")}
|
||||
>
|
||||
<Button
|
||||
data-action="prompt-attach"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
class="size-8 p-0"
|
||||
style={buttons()}
|
||||
onClick={pick}
|
||||
disabled={store.mode !== "normal"}
|
||||
tabIndex={store.mode === "normal" ? undefined : -1}
|
||||
aria-label={language.t("prompt.action.attachFile")}
|
||||
>
|
||||
<Icon name="plus" class="size-4.5" />
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DockShellForm>
|
||||
<Show when={store.mode === "normal" || store.mode === "shell"}>
|
||||
<DockTray attach="top">
|
||||
<div class="px-1.75 pt-5.5 pb-2 flex items-center gap-2 min-w-0">
|
||||
<div class="flex items-center gap-1.5 min-w-0 flex-1 relative">
|
||||
<div
|
||||
class="h-7 flex items-center gap-1.5 min-w-0 absolute inset-0"
|
||||
style={{
|
||||
padding: "0 0px 0 8px",
|
||||
...shell(),
|
||||
}}
|
||||
class="relative max-h-[240px] overflow-y-auto no-scrollbar"
|
||||
ref={(el) => (scrollRef = el)}
|
||||
style={{ "scroll-padding-bottom": space }}
|
||||
>
|
||||
<Icon name="console" />
|
||||
<span class="truncate text-13-medium text-text-base">{language.t("prompt.mode.shell")}</span>
|
||||
<div class="flex-1" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="text-text-base"
|
||||
onClick={() => {
|
||||
setStore("mode", "normal")
|
||||
<div
|
||||
data-component="prompt-input"
|
||||
ref={(el) => {
|
||||
editorRef = el
|
||||
props.ref?.(el)
|
||||
}}
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
aria-label={placeholder()}
|
||||
contenteditable="true"
|
||||
autocapitalize={store.mode === "normal" ? "sentences" : "off"}
|
||||
autocorrect={store.mode === "normal" ? "on" : "off"}
|
||||
spellcheck={store.mode === "normal"}
|
||||
inputMode="text"
|
||||
// @ts-expect-error
|
||||
autocomplete="off"
|
||||
onInput={handleInput}
|
||||
onPaste={handlePaste}
|
||||
onCompositionStart={handleCompositionStart}
|
||||
onCompositionEnd={handleCompositionEnd}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
classList={{
|
||||
"select-text": true,
|
||||
"w-full pl-3 pr-2 pt-2 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
|
||||
"[&_[data-type=file]]:text-syntax-property": true,
|
||||
"[&_[data-type=agent]]:text-syntax-type": true,
|
||||
"font-mono!": store.mode === "shell",
|
||||
}}
|
||||
style={{ "padding-bottom": space }}
|
||||
/>
|
||||
<div
|
||||
class="absolute top-0 inset-x-0 pl-3 pr-2 pt-2 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate"
|
||||
classList={{ "font-mono!": store.mode === "shell" }}
|
||||
style={{ "padding-bottom": space, display: prompt.dirty() ? "none" : undefined }}
|
||||
>
|
||||
{placeholder()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute inset-x-0 bottom-0"
|
||||
style={{
|
||||
height: space,
|
||||
background:
|
||||
"linear-gradient(to top, var(--surface-raised-stronger-non-alpha) calc(100% - 20px), transparent)",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div class="pointer-events-none absolute bottom-2 right-2 flex items-center gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept={ACCEPTED_FILE_TYPES.join(",")}
|
||||
class="hidden"
|
||||
onChange={(e) => {
|
||||
const list = e.currentTarget.files
|
||||
if (list) void addAttachments(Array.from(list))
|
||||
e.currentTarget.value = ""
|
||||
}}
|
||||
/>
|
||||
|
||||
<div class="flex items-center gap-1 pointer-events-auto">
|
||||
<Tooltip placement="top" inactive={!working() && blank()} value={tip()}>
|
||||
<IconButton
|
||||
data-action="prompt-submit"
|
||||
type="submit"
|
||||
disabled={!working() && blank()}
|
||||
tabIndex={store.mode === "normal" ? undefined : -1}
|
||||
icon={stopping() ? "stop" : store.mode === "shell" ? "arrow-undo-down" : "arrow-up"}
|
||||
variant="primary"
|
||||
class="size-8"
|
||||
aria-label={stopping() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pointer-events-none absolute bottom-2 left-2">
|
||||
<div
|
||||
aria-hidden={store.mode !== "normal"}
|
||||
class="pointer-events-auto"
|
||||
style={{
|
||||
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
|
||||
}}
|
||||
>
|
||||
{language.t("common.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 min-w-0 flex-1 h-7">
|
||||
<Show when={!agentsLoading()}>
|
||||
<div
|
||||
data-component="prompt-agent-control"
|
||||
style={agentsShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
title={language.t("prompt.action.attachFile")}
|
||||
keybind={command.keybind("file.attach")}
|
||||
>
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
gutter={4}
|
||||
title={language.t("command.agent.cycle")}
|
||||
keybind={command.keybind("agent.cycle")}
|
||||
<Button
|
||||
data-action="prompt-attach"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
class="size-8 p-0"
|
||||
style={buttons()}
|
||||
onClick={pick}
|
||||
disabled={store.mode !== "normal"}
|
||||
tabIndex={store.mode === "normal" ? undefined : -1}
|
||||
aria-label={language.t("prompt.action.attachFile")}
|
||||
>
|
||||
<Select
|
||||
size="normal"
|
||||
options={agentNames()}
|
||||
current={local.agent.current()?.name ?? ""}
|
||||
onSelect={(value) => {
|
||||
local.agent.set(value)
|
||||
restoreFocus()
|
||||
}}
|
||||
class="capitalize max-w-[160px] text-text-base"
|
||||
valueClass="truncate text-13-regular text-text-base"
|
||||
triggerStyle={control()}
|
||||
triggerProps={{ "data-action": "prompt-agent" }}
|
||||
variant="ghost"
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
<Icon name="plus" class="size-4.5" />
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DockShellForm>
|
||||
<Show when={store.mode === "normal" || store.mode === "shell"}>
|
||||
<DockTray attach="top">
|
||||
<div class="px-1.75 pt-5.5 pb-2 flex items-center gap-2 min-w-0">
|
||||
<div class="flex items-center gap-1.5 min-w-0 flex-1 relative">
|
||||
<div
|
||||
class="h-7 flex items-center gap-1.5 min-w-0 absolute inset-0"
|
||||
style={{
|
||||
padding: "0 0px 0 8px",
|
||||
...shell(),
|
||||
}}
|
||||
>
|
||||
<Icon name="console" />
|
||||
<span class="truncate text-13-medium text-text-base">{language.t("prompt.mode.shell")}</span>
|
||||
<div class="flex-1" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="text-text-base"
|
||||
onClick={() => {
|
||||
setStore("mode", "normal")
|
||||
}}
|
||||
>
|
||||
{language.t("common.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={!providersLoading()}>
|
||||
<Show when={store.mode !== "shell"}>
|
||||
<div
|
||||
data-component="prompt-model-control"
|
||||
style={providersShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
|
||||
>
|
||||
<Show
|
||||
when={providers.paid().length > 0}
|
||||
fallback={
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
gutter={4}
|
||||
title={language.t("command.model.choose")}
|
||||
keybind={command.keybind("model.choose")}
|
||||
>
|
||||
<Button
|
||||
data-action="prompt-model"
|
||||
as="div"
|
||||
variant="ghost"
|
||||
size="normal"
|
||||
class="min-w-0 max-w-[320px] text-13-regular text-text-base group"
|
||||
style={control()}
|
||||
onClick={() => {
|
||||
void import("@/components/dialog-select-model-unpaid").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectModelUnpaid model={local.model} />)
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Show when={local.model.current()?.provider?.id}>
|
||||
<ProviderIcon
|
||||
id={local.model.current()?.provider?.id ?? ""}
|
||||
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
||||
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
||||
/>
|
||||
</Show>
|
||||
<span class="truncate">
|
||||
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
|
||||
</span>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0" />
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
}
|
||||
>
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
gutter={4}
|
||||
title={language.t("command.model.choose")}
|
||||
keybind={command.keybind("model.choose")}
|
||||
>
|
||||
<ModelSelectorPopover
|
||||
model={local.model}
|
||||
triggerAs={Button}
|
||||
triggerProps={{
|
||||
variant: "ghost",
|
||||
size: "normal",
|
||||
style: control(),
|
||||
class: "min-w-0 max-w-[320px] text-13-regular text-text-base group",
|
||||
"data-action": "prompt-model",
|
||||
}}
|
||||
onClose={restoreFocus}
|
||||
>
|
||||
<Show when={local.model.current()?.provider?.id}>
|
||||
<ProviderIcon
|
||||
id={local.model.current()?.provider?.id ?? ""}
|
||||
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
||||
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
||||
/>
|
||||
</Show>
|
||||
<span class="truncate">
|
||||
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
|
||||
</span>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0" />
|
||||
</ModelSelectorPopover>
|
||||
</TooltipKeybind>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={variants().length > 2}>
|
||||
<div class="flex items-center gap-1.5 min-w-0 flex-1 h-7">
|
||||
<Show when={!agentsLoading()}>
|
||||
<div
|
||||
data-component="prompt-variant-control"
|
||||
style={providersShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
|
||||
data-component="prompt-agent-control"
|
||||
style={agentsShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
|
||||
>
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
gutter={4}
|
||||
title={language.t("command.model.variant.cycle")}
|
||||
keybind={command.keybind("model.variant.cycle")}
|
||||
title={language.t("command.agent.cycle")}
|
||||
keybind={command.keybind("agent.cycle")}
|
||||
>
|
||||
<Select
|
||||
size="normal"
|
||||
options={variants()}
|
||||
current={local.model.variant.current() ?? "default"}
|
||||
label={(x) => (x === "default" ? language.t("common.default") : x)}
|
||||
options={agentNames()}
|
||||
current={local.agent.current()?.name ?? ""}
|
||||
onSelect={(value) => {
|
||||
local.model.variant.set(value === "default" ? undefined : value)
|
||||
local.agent.set(value)
|
||||
restoreFocus()
|
||||
}}
|
||||
class="capitalize max-w-[160px] text-text-base"
|
||||
valueClass="truncate text-13-regular text-text-base"
|
||||
triggerStyle={control()}
|
||||
triggerProps={{ "data-action": "prompt-model-variant" }}
|
||||
triggerProps={{ "data-action": "prompt-agent" }}
|
||||
variant="ghost"
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
<Show when={!providersLoading()}>
|
||||
<Show when={store.mode !== "shell"}>
|
||||
<div
|
||||
data-component="prompt-model-control"
|
||||
style={providersShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
|
||||
>
|
||||
<Show
|
||||
when={providers.paid().length > 0}
|
||||
fallback={
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
gutter={4}
|
||||
title={language.t("command.model.choose")}
|
||||
keybind={command.keybind("model.choose")}
|
||||
>
|
||||
<Button
|
||||
data-action="prompt-model"
|
||||
as="div"
|
||||
variant="ghost"
|
||||
size="normal"
|
||||
class="min-w-0 max-w-[320px] text-13-regular text-text-base group"
|
||||
style={control()}
|
||||
onClick={() => {
|
||||
void import("@/components/dialog-select-model-unpaid").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectModelUnpaid model={local.model} />)
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Show when={local.model.current()?.provider?.id}>
|
||||
<ProviderIcon
|
||||
id={local.model.current()?.provider?.id ?? ""}
|
||||
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
||||
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
||||
/>
|
||||
</Show>
|
||||
<span class="truncate">
|
||||
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
|
||||
</span>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0" />
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
}
|
||||
>
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
gutter={4}
|
||||
title={language.t("command.model.choose")}
|
||||
keybind={command.keybind("model.choose")}
|
||||
>
|
||||
<ModelSelectorPopover
|
||||
model={local.model}
|
||||
triggerAs={Button}
|
||||
triggerProps={{
|
||||
variant: "ghost",
|
||||
size: "normal",
|
||||
style: control(),
|
||||
class: "min-w-0 max-w-[320px] text-13-regular text-text-base group",
|
||||
"data-action": "prompt-model",
|
||||
}}
|
||||
onClose={restoreFocus}
|
||||
>
|
||||
<Show when={local.model.current()?.provider?.id}>
|
||||
<ProviderIcon
|
||||
id={local.model.current()?.provider?.id ?? ""}
|
||||
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
||||
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
||||
/>
|
||||
</Show>
|
||||
<span class="truncate">
|
||||
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
|
||||
</span>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0" />
|
||||
</ModelSelectorPopover>
|
||||
</TooltipKeybind>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={variants().length > 2}>
|
||||
<div
|
||||
data-component="prompt-variant-control"
|
||||
style={providersShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
|
||||
>
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
gutter={4}
|
||||
title={language.t("command.model.variant.cycle")}
|
||||
keybind={command.keybind("model.variant.cycle")}
|
||||
>
|
||||
<Select
|
||||
size="normal"
|
||||
options={variants()}
|
||||
current={local.model.variant.current() ?? "default"}
|
||||
label={(x) => (x === "default" ? language.t("common.default") : x)}
|
||||
onSelect={(value) => {
|
||||
local.model.variant.set(value === "default" ? undefined : value)
|
||||
restoreFocus()
|
||||
}}
|
||||
class="capitalize max-w-[160px] text-text-base"
|
||||
valueClass="truncate text-13-regular text-text-base"
|
||||
triggerStyle={control()}
|
||||
triggerProps={{ "data-action": "prompt-model-variant" }}
|
||||
variant="ghost"
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DockTray>
|
||||
</Show>
|
||||
</DockTray>
|
||||
</Show>
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,3 +3,4 @@ export { SessionContextTab } from "./session-context-tab"
|
|||
export { SortableTab, FileVisual } from "./session-sortable-tab"
|
||||
export { SortableTerminalTab } from "./session-sortable-terminal-tab"
|
||||
export { NewSessionView } from "./session-new-view"
|
||||
export { NewSessionDesignView } from "./session-new-design-view"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,78 @@
|
|||
import type { JSX } from "solid-js"
|
||||
import { createMemo } from "solid-js"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useServer } from "@/context/server"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
import { WordmarkV2 } from "@opencode-ai/ui/v2/components/wordmark-v2.jsx"
|
||||
|
||||
const MAIN_WORKTREE = "main"
|
||||
|
||||
export function NewSessionDesignView(props: { worktree: string; children: JSX.Element }) {
|
||||
const globalSync = useGlobalSync()
|
||||
const layout = useLayout()
|
||||
const navigate = useNavigate()
|
||||
const sdk = useSDK()
|
||||
const server = useServer()
|
||||
const sync = useSync()
|
||||
|
||||
const projectRoot = createMemo(() => sync.project?.worktree ?? sdk.directory)
|
||||
const projects = createMemo(() => {
|
||||
const roots = globalSync.data.project.map((project) => project.worktree)
|
||||
if (roots.includes(projectRoot())) return roots
|
||||
return [projectRoot(), ...roots]
|
||||
})
|
||||
const branch = createMemo(() => sync.data.vcs?.branch ?? MAIN_WORKTREE)
|
||||
|
||||
const openProject = (directory: string | undefined) => {
|
||||
if (!directory) return
|
||||
if (directory === projectRoot()) return
|
||||
layout.projects.open(directory)
|
||||
server.projects.touch(directory)
|
||||
navigate(`/${base64Encode(directory)}/session`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-component="session-new-design" class="relative size-full overflow-hidden bg-v2-background-bg-deep">
|
||||
<div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
|
||||
<div class="w-full max-w-[720px]">
|
||||
<WordmarkV2 class="h-auto w-full text-v2-icon-icon-base" />
|
||||
<div class="mt-8">
|
||||
{props.children}
|
||||
<div class="mt-3 flex h-7 items-center gap-0 pl-2">
|
||||
<Select
|
||||
size="normal"
|
||||
variant="ghost"
|
||||
options={projects()}
|
||||
current={projectRoot()}
|
||||
label={getFilename}
|
||||
onSelect={openProject}
|
||||
class="max-w-[203px] justify-start text-text-base [&_[data-component=icon]]:text-v2-icon-icon-muted"
|
||||
valueClass="truncate text-[length:13px] font-[440] text-v2-text-text-faint"
|
||||
/>
|
||||
<div class="relative">
|
||||
<div class="pointer-events-none absolute left-2 top-1/2 z-10 flex size-4 -translate-y-1/2 items-center justify-center">
|
||||
<Icon name="branch" size="small" />
|
||||
</div>
|
||||
<Select
|
||||
size="normal"
|
||||
variant="ghost"
|
||||
options={[branch()]}
|
||||
current={branch()}
|
||||
class="max-w-[240px] justify-start text-text-base [&_[data-component=icon]]:text-v2-icon-icon-muted"
|
||||
valueClass="truncate pl-5 font-[440] text-v2-text-text-faint"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -7,8 +7,9 @@ import { Button } from "@opencode-ai/ui/button"
|
|||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||
import { useTheme } from "@opencode-ai/ui/theme/context"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/components/icon-button-v2.jsx"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/components/icon.jsx"
|
||||
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { getAvatarColors, useLayout, type LocalProject } from "@/context/layout"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { useLanguage } from "@/context/language"
|
||||
|
|
@ -18,6 +19,9 @@ import { applyPath, backPath, forwardPath } from "./titlebar-history"
|
|||
import { useGlobalSync } from "@/context/global-sync"
|
||||
import { decodeDirectory } from "@/pages/directory-layout"
|
||||
import { iife } from "@opencode-ai/core/util/iife"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { Avatar as AvatarV2 } from "@opencode-ai/ui/v2/components/avatar-v2.jsx"
|
||||
import { displayName, getProjectAvatarSource, projectForSession } from "@/pages/layout/helpers"
|
||||
|
||||
type TauriDesktopWindow = {
|
||||
startDragging?: () => Promise<void>
|
||||
|
|
@ -40,13 +44,21 @@ type TauriApi = {
|
|||
const tauriApi = () => (window as unknown as { __TAURI__?: TauriApi }).__TAURI__
|
||||
const currentDesktopWindow = () => tauriApi()?.window?.getCurrentWindow?.()
|
||||
const currentThemeWindow = () => tauriApi()?.webviewWindow?.getCurrentWebviewWindow?.()
|
||||
const titlebarHeight = 40
|
||||
const legacyTitlebarHeight = 40
|
||||
const v2TitlebarHeight = 44
|
||||
const minTitlebarZoom = 0.25
|
||||
const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px each.
|
||||
const USE_V2_TITLEBAR = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||
|
||||
const makeSessionHref = (b64Dir: string, sessionId: string) => `/${b64Dir}/session/${sessionId}`
|
||||
|
||||
export function Titlebar() {
|
||||
export type TitlebarUpdate = {
|
||||
version: () => string | undefined
|
||||
installing: () => boolean
|
||||
install: () => void
|
||||
}
|
||||
|
||||
export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
const layout = useLayout()
|
||||
const platform = usePlatform()
|
||||
const command = useCommand()
|
||||
|
|
@ -59,14 +71,16 @@ export function Titlebar() {
|
|||
|
||||
const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
|
||||
const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows")
|
||||
const electronWindows = createMemo(() => windows() && !tauriApi())
|
||||
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
|
||||
const web = createMemo(() => platform.platform === "web")
|
||||
const zoom = () => platform.webviewZoom?.() ?? 1
|
||||
const titlebarZoom = () => (windows() ? Math.max(zoom(), minTitlebarZoom) : zoom())
|
||||
const counterZoom = () => (windows() && titlebarZoom() < 1 ? 1 / titlebarZoom() : 1)
|
||||
const minHeight = () => {
|
||||
if (mac()) return `${titlebarHeight / zoom()}px`
|
||||
if (windows()) return `${titlebarHeight / Math.min(titlebarZoom(), 1)}px`
|
||||
const height = USE_V2_TITLEBAR ? v2TitlebarHeight : legacyTitlebarHeight
|
||||
if (mac()) return `${height / zoom()}px`
|
||||
if (windows()) return `${height / Math.min(titlebarZoom(), 1)}px`
|
||||
return undefined
|
||||
}
|
||||
const windowsControlsWidth = () => `${windowsControlsBaseWidth / Math.max(titlebarZoom(), 1)}px`
|
||||
|
|
@ -183,19 +197,47 @@ export function Titlebar() {
|
|||
|
||||
return (
|
||||
<header
|
||||
class="h-10 shrink-0 bg-background-base relative overflow-hidden flex flex-row"
|
||||
style={{ "min-height": minHeight(), "padding-left": mac() ? `${84 / zoom()}px` : 0 }}
|
||||
classList={{
|
||||
"shrink-0 relative overflow-hidden flex flex-row": true,
|
||||
"h-11 bg-v2-background-bg-deep": USE_V2_TITLEBAR,
|
||||
"h-10 bg-background-base": !USE_V2_TITLEBAR,
|
||||
}}
|
||||
style={{
|
||||
"min-height": minHeight(),
|
||||
"padding-left": mac() ? `${84 / zoom()}px` : 0,
|
||||
width: electronWindows() ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` : undefined,
|
||||
"max-width": electronWindows()
|
||||
? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))`
|
||||
: undefined,
|
||||
"align-self": electronWindows() ? "flex-start" : undefined,
|
||||
}}
|
||||
data-tauri-drag-region
|
||||
onMouseDown={drag}
|
||||
onDblClick={maximize}
|
||||
>
|
||||
<Switch>
|
||||
<Match when={import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"}>
|
||||
<Match when={USE_V2_TITLEBAR}>
|
||||
{(_) => {
|
||||
const globalSync = useGlobalSync()
|
||||
const navigate = useNavigate()
|
||||
const homeMatch = useMatch(() => "/")
|
||||
|
||||
type Tab = { dir: string; sessionId: string; params: any; href: string }
|
||||
const openNewSession = () => {
|
||||
if (params.dir) {
|
||||
navigate(`/${params.dir}/session`)
|
||||
return
|
||||
}
|
||||
|
||||
const project = layout.projects.list()[0]
|
||||
if (!project) {
|
||||
navigate("/")
|
||||
return
|
||||
}
|
||||
|
||||
navigate(`/${base64Encode(project.worktree)}/session`)
|
||||
}
|
||||
|
||||
type Tab = { dir: string; sessionId: string; href: string }
|
||||
|
||||
const [tabsStore, tabsStoreActions] = iife(() => {
|
||||
const [store, setStore] = createStore<Tab[]>(
|
||||
|
|
@ -205,7 +247,6 @@ export function Titlebar() {
|
|||
{
|
||||
dir: decodeDirectory(params.dir) ?? "",
|
||||
sessionId: params.id,
|
||||
params: { id: params.id, dir: params.dir },
|
||||
href: makeSessionHref(params.dir, params.id),
|
||||
},
|
||||
]
|
||||
|
|
@ -248,11 +289,15 @@ export function Titlebar() {
|
|||
tabsStoreActions.addTab({
|
||||
dir: decodeDirectory(params.dir) ?? "",
|
||||
sessionId: params.id,
|
||||
params: { id: params.id, dir: params.dir },
|
||||
href: makeSessionHref(params.dir, params.id),
|
||||
})
|
||||
})
|
||||
|
||||
const projects = createMemo(() => layout.projects.list())
|
||||
const projectByID = createMemo(
|
||||
() => new Map(projects().flatMap((project) => (project.id ? [[project.id, project] as const] : []))),
|
||||
)
|
||||
|
||||
const tabsEnriched = iife(() => {
|
||||
const base = mapArray(
|
||||
() => tabsStore,
|
||||
|
|
@ -267,78 +312,73 @@ export function Titlebar() {
|
|||
})
|
||||
|
||||
return (
|
||||
<div class="h-full flex-1 flex flex-row items-center gap-1.5 pr-3">
|
||||
<div
|
||||
class="h-full flex-1 flex flex-row items-center gap-1.5 pr-3 py-2"
|
||||
classList={{
|
||||
"pl-2": mac(),
|
||||
"pl-4": !mac(),
|
||||
}}
|
||||
>
|
||||
<ChannelIndicator />
|
||||
<Show when={windows() || linux()}>
|
||||
<WindowsAppMenu command={command} platform={platform} />
|
||||
<WindowsAppMenu command={command} platform={platform} variant="v2" />
|
||||
</Show>
|
||||
<IconButtonV2
|
||||
as="a"
|
||||
href="/"
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
class="!w-8"
|
||||
state={!!useMatch(() => "/")() ? "pressed" : undefined}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path
|
||||
d="M13.9948 11.668H9.32812M11.6641 9.33203V13.9987M6.66667 9.33203V13.9987H2V9.33203H6.66667ZM6.66667 2V6.66667H2V2H6.66667ZM13.9948 2V6.66667H9.32812V2H13.9948Z"
|
||||
stroke="currentColor"
|
||||
stroke-miterlimit="10"
|
||||
stroke-linecap="square"
|
||||
/>
|
||||
</svg>
|
||||
</IconButtonV2>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<For each={tabsEnriched()}>
|
||||
{(tab, i) => (
|
||||
<>
|
||||
{i() !== 0 && <div class="w-[1.5px] h-3 rounded-full bg-[var(--v2-background-bg-layer-02)]" />}
|
||||
<TabNavItem
|
||||
href={tab.href}
|
||||
title={tab.info.title}
|
||||
onClose={() => tabsStoreActions.removeTab(tab.href)}
|
||||
hideClose={tabsEnriched().length < 2}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<button>
|
||||
<div class="p-1.5">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
class="size-4"
|
||||
>
|
||||
<path
|
||||
d="M7.99978 2.88867V13.1109M2.88867 7.99978H13.1109"
|
||||
stroke="#808080"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
as="a"
|
||||
href="/"
|
||||
class="!w-9"
|
||||
icon={<IconV2 name="grid-plus" />}
|
||||
state={!!homeMatch() ? "pressed" : undefined}
|
||||
/>
|
||||
|
||||
<div class="flex-1" />
|
||||
{/*<button class="px-2.5 py-1.5 bg-[rgba(0,0,0,0.08)] rounded-[6px]">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
class="size-4"
|
||||
<div class="flex min-w-0 flex-1 flex-row items-center gap-1.5 overflow-hidden">
|
||||
<div class="flex min-w-0 flex-row items-center gap-1.5 overflow-hidden">
|
||||
<For each={tabsEnriched()}>
|
||||
{(tab, i) => (
|
||||
<>
|
||||
{i() !== 0 && (
|
||||
<div class="w-[1.5px] h-3 shrink-0 rounded-full bg-[var(--v2-background-bg-layer-02)]" />
|
||||
)}
|
||||
<TabNavItem
|
||||
href={tab.href}
|
||||
title={tab.info.title}
|
||||
project={projectForSession(tab.info, projects(), projectByID())}
|
||||
directory={tab.dir}
|
||||
onClose={() => tabsStoreActions.removeTab(tab.href)}
|
||||
hideClose={tabsEnriched().length < 2}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<Show
|
||||
when={creating() && params.dir}
|
||||
fallback={
|
||||
<IconButtonV2
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
class="shrink-0"
|
||||
icon={<IconV2 name="plus" />}
|
||||
onClick={openNewSession}
|
||||
aria-label={language.t("command.session.new")}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<path
|
||||
d="M10.4443 2.44436V13.5555M1.55546 13.5554H14.4443V2.44434H1.55542L1.55546 13.5554Z"
|
||||
stroke="#3A3A3A"
|
||||
<NewSessionTabItem
|
||||
href={`/${params.dir}/session`}
|
||||
title={language.t("command.session.new")}
|
||||
onClose={() => navigate(tabsEnriched().at(-1)?.href ?? "/")}
|
||||
/>
|
||||
</svg>
|
||||
</button>*/}
|
||||
</Show>
|
||||
<div class="min-w-0 flex-1" />
|
||||
</div>
|
||||
<TitlebarUpdatePill update={props.update} />
|
||||
<Show when={windows() && !electronWindows()}>
|
||||
<div data-tauri-decorum-tb class="flex flex-row" />
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
|
|
@ -358,7 +398,7 @@ export function Titlebar() {
|
|||
<WindowsAppMenu command={command} platform={platform} />
|
||||
</Show>
|
||||
<Show when={mac()}>
|
||||
<div class="h-full shrink-0" style={{ width: `${72 / zoom()}px` }} />
|
||||
{/*<div class="h-full shrink-0" style={{ width: `${72 / zoom()}px` }} />*/}
|
||||
<div class="xl:hidden w-10 shrink-0 flex items-center justify-center">
|
||||
<IconButton
|
||||
icon="menu"
|
||||
|
|
@ -502,19 +542,51 @@ export function Titlebar() {
|
|||
)
|
||||
}
|
||||
|
||||
function TabNavItem(props: { href: string; title: string; hideClose?: boolean; onClose: () => void }) {
|
||||
function TitlebarUpdatePill(props: { update?: TitlebarUpdate }) {
|
||||
const language = useLanguage()
|
||||
const version = () => props.update?.version()
|
||||
|
||||
return (
|
||||
<Show when={version() !== undefined}>
|
||||
<button
|
||||
type="button"
|
||||
class="h-5 shrink-0 rounded-[27px] bg-[var(--v2-background-bg-accent)] px-2.5 text-[11px] font-[530] leading-[1.1] tracking-[-0.04px] text-[var(--v2-text-text-contrast)] disabled:opacity-60"
|
||||
onClick={() => props.update?.install()}
|
||||
disabled={props.update?.installing()}
|
||||
aria-label={language.t("toast.update.action.installRestart")}
|
||||
title={version() ? `Update ${version()}` : undefined}
|
||||
>
|
||||
Update
|
||||
</button>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function DesktopTitlebarIconButton(props: Parameters<typeof IconButtonV2>[0]) {
|
||||
return
|
||||
}
|
||||
|
||||
function TabNavItem(props: {
|
||||
href: string
|
||||
title: string
|
||||
project?: LocalProject
|
||||
directory: string
|
||||
hideClose?: boolean
|
||||
onClose: () => void
|
||||
}) {
|
||||
const match = useMatch(() => props.href)
|
||||
const isActive = () => !!match()
|
||||
return (
|
||||
<div
|
||||
class="group flex flex-row items-center max-w-60 whitespace-nowrap [--tab-bg:var(--v2-background-bg-deep)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] bg-[var(--tab-bg)] h-7 rounded-[6px] relative overflow-hidden"
|
||||
class="group relative flex h-7 min-w-24 max-w-60 flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] pl-1.5 pr-8 [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
|
||||
data-active={isActive()}
|
||||
>
|
||||
<a
|
||||
href={props.href}
|
||||
class="w-full h-full pl-1.5 flex-1 max-w-full flex flex-row items-center overflow-hidden font-medium"
|
||||
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 overflow-hidden text-[13px] font-medium leading-none tracking-[-0.04px] text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base"
|
||||
>
|
||||
{props.title}
|
||||
<ProjectTabAvatar project={props.project} directory={props.directory} />
|
||||
<span class="truncate">{props.title}</span>
|
||||
</a>
|
||||
|
||||
<div class="absolute right-0 inset-y-0 flex flex-row items-center pr-1 py-1 w-8 pl-2">
|
||||
|
|
@ -530,23 +602,60 @@ function TabNavItem(props: { href: string; title: string; hideClose?: boolean; o
|
|||
variant="ghost-muted"
|
||||
class="opacity-0 group-hover:opacity-100 group-data-[active='true']:opacity-100"
|
||||
onClick={props.onClose}
|
||||
icon={
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
class="size-4"
|
||||
>
|
||||
<path d="M4.25 11.75L11.75 4.25M11.75 11.75L4.25 4.25" stroke="currentColor" />
|
||||
</svg>
|
||||
}
|
||||
icon={<IconV2 name="xmark-small" />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProjectTabAvatar(props: { project?: LocalProject; directory: string }) {
|
||||
return (
|
||||
<AvatarV2
|
||||
fallback={displayName(props.project ?? { worktree: props.directory })}
|
||||
src={getProjectAvatarSource(props.project?.id, props.project?.icon)}
|
||||
kind="org"
|
||||
size="small"
|
||||
{...getAvatarColors(props.project?.icon?.color)}
|
||||
class="size-4 rounded"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function NewSessionTabItem(props: { href: string; title: string; onClose: () => void }) {
|
||||
return (
|
||||
<div class="group relative flex h-7 w-[135px] min-w-24 max-w-60 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--v2-overlay-simple-overlay-pressed)] pl-1.5 pr-8 whitespace-nowrap focus-within:outline focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-[var(--v2-border-border-focus)]">
|
||||
<a
|
||||
href={props.href}
|
||||
aria-current="page"
|
||||
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 overflow-hidden text-[13px] font-medium leading-none text-[var(--v2-text-text-base)]"
|
||||
>
|
||||
<span class="flex size-4 shrink-0 rotate-90 items-center justify-center">
|
||||
<IconV2 name="edit" />
|
||||
</span>
|
||||
<span class="truncate">{props.title}</span>
|
||||
</a>
|
||||
<div class="absolute right-0 inset-y-0 flex w-7 items-center justify-center">
|
||||
<IconButtonV2
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
props.onClose()
|
||||
}}
|
||||
icon={<IconV2 name="xmark-small" />}
|
||||
aria-label="Close tab"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ChannelIndicator() {
|
||||
return (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import { Show, type JSX } from "solid-js"
|
|||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/components/icon-button-v2.jsx"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/components/icon.jsx"
|
||||
|
||||
import { useCommand } from "@/context/command"
|
||||
import { DESKTOP_MENU, desktopMenuVisible, type DesktopMenuAction, type DesktopMenuEntry } from "@/desktop-menu"
|
||||
|
|
@ -10,6 +12,7 @@ import { usePlatform } from "@/context/platform"
|
|||
export function WindowsAppMenu(props: {
|
||||
command: ReturnType<typeof useCommand>
|
||||
platform: ReturnType<typeof usePlatform>
|
||||
variant?: "legacy" | "v2"
|
||||
}) {
|
||||
let lastFocused: HTMLElement | undefined
|
||||
|
||||
|
|
@ -45,15 +48,29 @@ export function WindowsAppMenu(props: {
|
|||
|
||||
return (
|
||||
<DropdownMenu gutter={4} modal={false} placement="bottom-start">
|
||||
<DropdownMenu.Trigger
|
||||
as={IconButton}
|
||||
icon="menu"
|
||||
variant="ghost"
|
||||
class="titlebar-icon rounded-md shrink-0"
|
||||
aria-label="OpenCode menu"
|
||||
onPointerDown={rememberFocus}
|
||||
onKeyDown={rememberFocus}
|
||||
/>
|
||||
{props.variant === "v2" ? (
|
||||
<div data-component="desktop-icon-button" class="flex h-7 w-9 shrink-0 items-center justify-center rounded-[6px] px-1">
|
||||
<DropdownMenu.Trigger
|
||||
as={IconButtonV2}
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
icon={<IconV2 name="menu" />}
|
||||
aria-label="OpenCode menu"
|
||||
onPointerDown={rememberFocus}
|
||||
onKeyDown={rememberFocus}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<DropdownMenu.Trigger
|
||||
as={IconButton}
|
||||
icon="menu"
|
||||
variant="ghost"
|
||||
class="titlebar-icon rounded-md shrink-0"
|
||||
aria-label="OpenCode menu"
|
||||
onPointerDown={rememberFocus}
|
||||
onKeyDown={rememberFocus}
|
||||
/>
|
||||
)}
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content class="desktop-app-menu">
|
||||
<DropdownMenu.Group>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue