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()} -
-
- -