From b207e32249263a9bf9fba5b6d42ac308d618d766 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Thu, 21 May 2026 15:48:13 +1000 Subject: [PATCH] feat(app): add desktop v2 home, session entry, and titlebar (#28442) Co-authored-by: Brendan Allan --- .../app/e2e/smoke/session-timeline.spec.ts | 47 +- .../src/components/dialog-edit-project.tsx | 2 +- packages/app/src/components/prompt-input.tsx | 847 ++++++++++++------ packages/app/src/components/session/index.ts | 1 + .../session/session-new-design-view.tsx | 78 ++ packages/app/src/components/titlebar.tsx | 291 ++++-- .../app/src/components/windows-app-menu.tsx | 35 +- packages/app/src/i18n/en.ts | 8 + packages/app/src/i18n/zh.ts | 8 + packages/app/src/index.css | 2 - packages/app/src/pages/home.tsx | 681 ++++++++++++-- packages/app/src/pages/layout.tsx | 152 ++-- packages/app/src/pages/layout/helpers.ts | 24 + .../app/src/pages/layout/sidebar-items.tsx | 11 +- packages/app/src/pages/session.tsx | 123 +-- .../composer/session-composer-region.tsx | 10 +- .../src/pages/session/message-timeline.tsx | 13 +- packages/desktop/src/main/updater.ts | 20 +- packages/ui/script/colors.txt | 55 ++ packages/ui/src/components/logo.tsx | 2 +- packages/ui/src/styles/tailwind/colors.css | 51 +- packages/ui/src/v2/components/avatar-v2.css | 10 +- packages/ui/src/v2/components/button-v2.css | 48 +- packages/ui/src/v2/components/icon.tsx | 81 +- packages/ui/src/v2/components/select-v2.tsx | 6 +- packages/ui/src/v2/components/wordmark-v2.tsx | 92 ++ 26 files changed, 2065 insertions(+), 633 deletions(-) create mode 100644 packages/app/src/components/session/session-new-design-view.tsx create mode 100644 packages/ui/src/v2/components/wordmark-v2.tsx diff --git a/packages/app/e2e/smoke/session-timeline.spec.ts b/packages/app/e2e/smoke/session-timeline.spec.ts index 3dda017dde..296791bcd8 100644 --- a/packages/app/e2e/smoke/session-timeline.spec.ts +++ b/packages/app/e2e/smoke/session-timeline.spec.ts @@ -1,4 +1,5 @@ import { expect, test, type Page } from "@playwright/test" +import { base64Encode } from "@opencode-ai/core/util/encode" import { fixture, pageMessages } from "./session-timeline.fixture" import { trackPageErrors, expectNoSmokeErrors } from "../utils/errors" import { mockOpenCodeServer } from "../utils/mock-server" @@ -37,12 +38,12 @@ test.describe("smoke: session timeline", () => { project: fixture.project, pageMessages, }) - await configureSmokePage(page) + await configureSmokePage(page, fixture.directory) - await openProject(page, "SmokeProject") - await navigateToSession(page, fixture.sourceID, fixture.expected.sourceTitle) - await expectSessionReady(page, "smoke-project") - await navigateToSession(page, fixture.targetID, fixture.expected.targetTitle) + await selectHomeProject(page, fixture.project.name) + await navigateToSession(page, fixture.directory, fixture.sourceID, fixture.expected.sourceTitle) + await expectSessionReady(page) + await navigateToSession(page, fixture.directory, fixture.targetID, fixture.expected.targetTitle) const expectedPartIDs = fixture.expected.targetPartIDs const expectedMessageIDs = fixture.expected.targetMessageIDs await expectSessionTimelineReady(page, expectedPartIDs, expectedMessageIDs, errors) @@ -50,7 +51,7 @@ test.describe("smoke: session timeline", () => { }) }) -async function configureSmokePage(page: Page) { +async function configureSmokePage(page: Page, directory: string) { await page.addInitScript(() => { localStorage.setItem( "settings.v3", @@ -63,7 +64,23 @@ async function configureSmokePage(page: Page) { }, }), ) + }) + await page.addInitScript((directory) => { + localStorage.setItem( + "opencode.global.dat:server", + JSON.stringify({ + projects: { + local: [{ worktree: directory, expanded: true }], + }, + lastProject: { + local: directory, + }, + }), + ) + }, directory) + + await page.addInitScript(() => { const smoke = window as SmokeWindow smoke.__timelineSmokeErrorToasts = [] smoke.__timelineSmokeForbiddenText = [] @@ -392,21 +409,17 @@ function expectCompleteScroll( expect(expectedPartIDs.length).toBe(331) } -async function openProject(page: Page, projectName: string) { +async function selectHomeProject(page: Page, projectName: string) { await page.goto("/") - await page.getByRole("button", { name: new RegExp(projectName, "i") }).click() + await page.locator('[data-component="home-project-row"]').filter({ hasText: new RegExp(projectName, "i") }).click() + await expect(page).toHaveURL(/\/$/) } -async function navigateToSession(page: Page, sessionId: string, expectedTitle: string) { - // Use evaluate to click to avoid strict visibility/animation issues during rapid e2e navigation - await page - .locator(`a[href*="${sessionId}"]`) - .first() - .evaluate((el) => (el as HTMLElement).click()) +async function navigateToSession(page: Page, directory: string, sessionId: string, expectedTitle: string) { + await page.goto(`/${base64Encode(directory)}/session/${sessionId}`) await expect(page.getByRole("heading", { name: expectedTitle })).toBeVisible() } -async function expectSessionReady(page: Page, projectName: string) { - await expect(page.getByText(projectName).first()).toBeVisible() - await expect(page.getByText("Ask anything...")).toBeVisible() +async function expectSessionReady(page: Page) { + await expect(page.getByRole("textbox", { name: /Ask anything/i })).toBeVisible() } diff --git a/packages/app/src/components/dialog-edit-project.tsx b/packages/app/src/components/dialog-edit-project.tsx index b4b69246cb..50b9daeef2 100644 --- a/packages/app/src/components/dialog-edit-project.tsx +++ b/packages/app/src/components/dialog-edit-project.tsx @@ -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 diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index e07b2f1864..4e075fd95e 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -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 = (props) => { const sdk = useSDK() const queryOptions = useQueryOptions() @@ -1055,6 +1072,21 @@ export const PromptInput: Component = (props) => { readClipboardImage: platform.readClipboardImage, }) + const fileAttachmentInput = () => ( + (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 = (props) => { (p) => p, ) + const designPlaceholder = () => { + if (store.mode === "shell") return placeholder() + return "Ask anything, / for commands, @ for context..." + } + + const modelControl = () => ( + + 0} + fallback={ + + + + } + > + + + + + + {local.model.current()?.name ?? language.t("dialog.model.select.title")} + + + + + + ) + + 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 ( -
+
{(promptReady(), null)} = (props) => { commandKeybind={command.keybind} t={(key) => language.t(key as Parameters[0])} /> - - - { - 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[0])} - /> - - dialog.show(() => ) - } - onRemove={removeAttachment} - removeLabel={language.t("prompt.attachment.remove")} - /> -
{ - const target = e.target - if (!(target instanceof HTMLElement)) return - if (target.closest('[data-action="prompt-attach"], [data-action="prompt-submit"]')) { - return - } - editorRef?.focus() - }} - > -
(scrollRef = el)} - style={{ "scroll-padding-bottom": space }} - > -
{ - 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 }} - /> -
- {placeholder()} -
-
- -