diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 15f865b742..1c41a66faa 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -339,8 +339,7 @@ jobs: env: OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }} GH_TOKEN: ${{ steps.committer.outputs.token }} - CSC_LINK: ${{ secrets.APPLE_CERTIFICATE }} - CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + CSC_KEYCHAIN: build.keychain APPLE_API_KEY: ${{ runner.temp }}/apple-api-key.p8 APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY }} APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} diff --git a/bun.lock b/bun.lock index 9f6ef7b628..e905ada8c5 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.18.9", + "version": "1.18.11", "dependencies": { "@corvu/drawer": "catalog:", "@dnd-kit/abstract": "0.5.0", @@ -96,7 +96,7 @@ }, "packages/cli": { "name": "@opencode-ai/cli", - "version": "1.18.9", + "version": "1.18.11", "bin": { "lildax": "./bin/lildax.cjs", }, @@ -144,7 +144,7 @@ }, "packages/codemode": { "name": "@opencode-ai/codemode", - "version": "1.18.9", + "version": "1.18.11", "dependencies": { "acorn": "8.15.0", "effect": "catalog:", @@ -158,7 +158,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.18.9", + "version": "1.18.11", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -194,7 +194,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.18.9", + "version": "1.18.11", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -221,7 +221,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.18.9", + "version": "1.18.11", "dependencies": { "@ai-sdk/anthropic": "3.0.82", "@ai-sdk/openai": "3.0.48", @@ -243,7 +243,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.18.9", + "version": "1.18.11", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -267,7 +267,7 @@ }, "packages/console/support": { "name": "@opencode-ai/console-support", - "version": "1.18.9", + "version": "1.18.11", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@opencode-ai/console-core": "workspace:*", @@ -287,7 +287,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "1.18.9", + "version": "1.18.11", "bin": { "opencode": "./bin/opencode", }, @@ -381,16 +381,16 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.18.9", + "version": "1.18.11", "dependencies": { "@zip.js/zip.js": "2.7.62", + "drizzle-orm": "catalog:", "effect": "catalog:", "electron-context-menu": "4.1.2", "electron-log": "^5", "electron-store": "11.0.2", "electron-updater": "6.8.9", "electron-window-state": "^5.0.3", - "marked": "^15", }, "devDependencies": { "@actions/artifact": "4.0.0", @@ -435,7 +435,7 @@ }, "packages/effect-drizzle-sqlite": { "name": "@opencode-ai/effect-drizzle-sqlite", - "version": "1.18.9", + "version": "1.18.11", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -449,7 +449,7 @@ }, "packages/effect-sqlite-node": { "name": "@opencode-ai/effect-sqlite-node", - "version": "1.18.9", + "version": "1.18.11", "dependencies": { "effect": "catalog:", }, @@ -461,7 +461,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.18.9", + "version": "1.18.11", "dependencies": { "@hono/standard-validator": "catalog:", "@opencode-ai/core": "workspace:*", @@ -493,7 +493,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.18.9", + "version": "1.18.11", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -509,7 +509,7 @@ }, "packages/http-recorder": { "name": "@opencode-ai/http-recorder", - "version": "1.18.9", + "version": "1.18.11", "dependencies": { "@effect/platform-node": "4.0.0-beta.83", "@effect/platform-node-shared": "4.0.0-beta.83", @@ -540,7 +540,7 @@ }, "packages/llm": { "name": "@opencode-ai/llm", - "version": "1.18.9", + "version": "1.18.11", "dependencies": { "@opencode-ai/schema": "workspace:*", "@smithy/eventstream-codec": "4.2.14", @@ -559,7 +559,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.18.9", + "version": "1.18.11", "bin": { "opencode": "./bin/opencode", }, @@ -690,7 +690,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.18.9", + "version": "1.18.11", "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode-ai/sdk": "workspace:*", @@ -766,7 +766,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.18.9", + "version": "1.18.11", "dependencies": { "cross-spawn": "catalog:", }, @@ -781,7 +781,7 @@ }, "packages/server": { "name": "@opencode-ai/server", - "version": "1.18.9", + "version": "1.18.11", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/protocol": "workspace:*", @@ -796,7 +796,7 @@ }, "packages/session-ui": { "name": "@opencode-ai/session-ui", - "version": "1.18.9", + "version": "1.18.11", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13-v2.tgz", @@ -811,7 +811,6 @@ "@solid-primitives/media": "2.3.3", "@solid-primitives/resize-observer": "2.1.3", "@solidjs/meta": "catalog:", - "@solidjs/router": "catalog:", "diff": "catalog:", "dompurify": "3.3.1", "fuzzysort": "catalog:", @@ -841,7 +840,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.18.9", + "version": "1.18.11", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -854,7 +853,7 @@ }, "packages/stats/app": { "name": "@opencode-ai/stats-app", - "version": "1.18.9", + "version": "1.18.11", "dependencies": { "@ibm/plex": "6.4.1", "@kobalte/core": "catalog:", @@ -888,7 +887,7 @@ }, "packages/stats/core": { "name": "@opencode-ai/stats-core", - "version": "1.18.9", + "version": "1.18.11", "dependencies": { "@aws-sdk/client-athena": "3.933.0", "@planetscale/database": "1.19.0", @@ -907,7 +906,7 @@ }, "packages/stats/server": { "name": "@opencode-ai/stats-server", - "version": "1.18.9", + "version": "1.18.11", "dependencies": { "@aws-sdk/client-firehose": "3.933.0", "@effect/platform-node": "catalog:", @@ -949,7 +948,7 @@ }, "packages/tui": { "name": "@opencode-ai/tui", - "version": "1.18.9", + "version": "1.18.11", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/plugin": "workspace:*", @@ -976,7 +975,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.18.9", + "version": "1.18.11", "dependencies": { "@kobalte/core": "catalog:", "@pierre/diffs": "catalog:", @@ -1028,7 +1027,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.18.9", + "version": "1.18.11", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", @@ -6045,8 +6044,6 @@ "@opencode-ai/desktop/@actions/artifact": ["@actions/artifact@4.0.0", "", { "dependencies": { "@actions/core": "^1.10.0", "@actions/github": "^6.0.1", "@actions/http-client": "^2.1.0", "@azure/core-http": "^3.0.5", "@azure/storage-blob": "^12.15.0", "@octokit/core": "^5.2.1", "@octokit/plugin-request-log": "^1.0.4", "@octokit/plugin-retry": "^3.0.9", "@octokit/request": "^8.4.1", "@octokit/request-error": "^5.1.1", "@protobuf-ts/plugin": "^2.2.3-alpha.1", "archiver": "^7.0.1", "jwt-decode": "^3.1.2", "unzip-stream": "^0.3.1" } }, "sha512-HCc2jMJRAfviGFAh0FsOR/jNfWhirxl7W6z8zDtttt0GltwxBLdEIjLiweOPFl9WbyJRW1VWnPUSAixJqcWUMQ=="], - "@opencode-ai/desktop/marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], - "@opencode-ai/desktop/typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="], "@opencode-ai/llm/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.14", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-erZq0nOIpzfeZdCyzZjdJb4nVSKLUmSkaQUVkRGQTXs30gyUGeKnrYEg+Xe1W5gE3aReS7IgsvANwVPxSzY6Pw=="], diff --git a/nix/hashes.json b/nix/hashes.json index a7b441535c..25f0e76812 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-fK6zAHJC3ut/KUdfqLPxVMH7Z1yv65YZ7qcHF45kPas=", - "aarch64-linux": "sha256-CSWZY/vVUCKzlwfisQQHqHjawUwL0BRc8Y9o2y/QZYA=", - "aarch64-darwin": "sha256-IyFm5NbnU63BaOO/F4/v1exz3VbvkY96yjg9iun+O9Q=", - "x86_64-darwin": "sha256-XnXFoUxqXmEx7AKx5YhaJuLY2NYMtI0ICafaenU5L6k=" + "x86_64-linux": "sha256-a7NyYa9vRUEqDfZNDPXXmFO58RDEgioyuGSl5CPBvxo=", + "aarch64-linux": "sha256-l4OJtSEllHvRhktjcaJYwkXBSaJvsIoyoLusbZfYMcM=", + "aarch64-darwin": "sha256-IIl0BQGs1/HLFh0auiQjiwfSQ2nfHcK2G2BAphYW59c=", + "x86_64-darwin": "sha256-vVeuPyd4ZIRYrHouphTuEb4rkRZLKKTAHe840jNh9rU=" } } diff --git a/packages/app/e2e/regression/project-picker-recent-search.spec.ts b/packages/app/e2e/regression/project-picker-recent-search.spec.ts new file mode 100644 index 0000000000..2cdb0b4a03 --- /dev/null +++ b/packages/app/e2e/regression/project-picker-recent-search.spec.ts @@ -0,0 +1,60 @@ +import { expect, test } from "@playwright/test" +import type { Page } from "@playwright/test" +import { fixture, pageMessages } from "../smoke/session-timeline.fixture" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectAppVisible } from "../utils/waits" + +const NAMES = ["alpha-service", "bravo-web", "charlie-api", "delta-tools", "echo-infra", "foxtrot-docs"] +const worktrees = NAMES.map((name) => `/opencode-demo/${name}`) + +// The sixth project sits outside the five-item recent cap, so it is only reachable if the +// dialog hands every recent project to the list filter instead of a pre-truncated slice. +const OUTSIDE_CAP = "foxtrot-docs" + +// Dialog rows carry data-directory-path; the sidebar project list does not, so this +// scopes assertions to the picker instead of matching the sidebar entry of the same name. +const rows = (page: Page) => page.locator("[data-directory-path]") +const row = (page: Page, name: string) => page.locator(`[data-directory-path*="${name}"]`) + +async function openProjectDialog(page: Page) { + await mockOpenCodeServer(page, { + sessions: fixture.sessions, + provider: fixture.provider, + directory: fixture.directory, + project: fixture.project, + pageMessages, + fileList: () => [], + findFiles: () => [], + }) + await page.addInitScript((dirs) => { + localStorage.setItem( + "opencode.global.dat:server", + JSON.stringify({ + projects: { local: dirs.map((worktree: string) => ({ worktree, expanded: false })) }, + lastProject: {}, + }), + ) + }, worktrees) + await page.goto("/") + const add = page.getByRole("button", { name: "Add project" }).first() + await expectAppVisible(add) + await add.click() + await expect(rows(page)).toHaveCount(5) + return page.getByRole("textbox").last() +} + +test("searches every recent project, not just the five most recent", async ({ page }) => { + const search = await openProjectDialog(page) + await expect(row(page, OUTSIDE_CAP)).toHaveCount(0) + + await search.fill("foxtrot") + + await expect(row(page, OUTSIDE_CAP)).toHaveCount(1) +}) + +test("still caps the idle recent list at five projects", async ({ page }) => { + await openProjectDialog(page) + + await expect(row(page, NAMES[4])).toHaveCount(1) + await expect(row(page, OUTSIDE_CAP)).toHaveCount(0) +}) diff --git a/packages/app/package.json b/packages/app/package.json index 08eb9a059f..2dd4a05d86 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.18.9", + "version": "1.18.11", "description": "", "type": "module", "exports": { diff --git a/packages/app/src/components/debug-bar.tsx b/packages/app/src/components/debug-bar.tsx index 67c9a89929..adadeda303 100644 --- a/packages/app/src/components/debug-bar.tsx +++ b/packages/app/src/components/debug-bar.tsx @@ -66,7 +66,7 @@ function Cell(props: {
- {props.label} -
-
- {props.value} +
+ {props.label} +
+
+ {props.value} +
) @@ -116,15 +124,25 @@ function FocusCell(props: { active: boolean; inline?: boolean; onClick: () => vo aria-pressed={props.active} classList={{ "flex min-w-0 items-center font-mono uppercase hover:bg-surface-raised-base focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-border-focus": true, - "min-h-[20px] w-fit flex-row justify-start gap-1.5 rounded px-1.5 py-0.5 text-left": !!props.inline, + "min-h-[20px] w-fit justify-start rounded px-1.5 py-0.5 text-left": !!props.inline, "min-h-[42px] w-full flex-col justify-center rounded-[8px] px-0.5 py-1 text-center": !props.inline, "bg-surface-raised-base text-text-strong": props.active, }} onClick={props.onClick} > - FOCUS - - {props.active ? "ON" : "OFF"} + + FOCUS + + {props.active ? "ON" : "OFF"} + ) diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx index 8291bfc947..0267b4b4ef 100644 --- a/packages/app/src/components/dialog-connect-provider.tsx +++ b/packages/app/src/components/dialog-connect-provider.tsx @@ -29,7 +29,7 @@ import { } from "solid-js" import { createStore, produce } from "solid-js/store" import { useParams } from "@solidjs/router" -import { Link } from "@/components/link" +import { ExternalLink } from "@/components/external-link" import { useServerSDK } from "@/context/server-sdk" import { useServerSync } from "@/context/server-sync" import { useLanguage } from "@/context/language" @@ -836,12 +836,12 @@ function ProviderConnection(props: {
{language.t("provider.connect.opencodeZen.line2")}
{language.t("provider.connect.opencodeZen.visit.prefix")} - {language.t("provider.connect.opencodeZen.visit.link")} - + {language.t("provider.connect.opencodeZen.visit.suffix")}
@@ -886,9 +886,9 @@ function ProviderConnection(props: {
{language.t("provider.connect.opencodeZen.line2")}
{language.t("provider.connect.opencodeZen.visit.prefix")} - + {language.t("provider.connect.opencodeZen.visit.link")} - + {language.t("provider.connect.opencodeZen.visit.suffix")}
@@ -967,9 +967,9 @@ function ProviderConnection(props: {
{language.t("provider.connect.oauth.code.visit.prefix")} - + {language.t("provider.connect.oauth.code.visit.link")} - + {language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
@@ -1006,7 +1006,9 @@ function ProviderConnection(props: {
{language.t("provider.connect.oauth.code.visit.prefix")} - {language.t("provider.connect.oauth.code.visit.link")} + + {language.t("provider.connect.oauth.code.visit.link")} + {language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
@@ -1077,7 +1079,9 @@ function ProviderConnection(props: {
{language.t("provider.connect.oauth.auto.visit.prefix")} - {language.t("provider.connect.oauth.auto.visit.link")} + + {language.t("provider.connect.oauth.auto.visit.link")} + {language.t("provider.connect.oauth.auto.visit.suffix", { provider: provider().name })}

{language.t("provider.custom.description.prefix")} - + {language.t("provider.custom.description.link")} - + {language.t("provider.custom.description.suffix")}

diff --git a/packages/app/src/components/dialog-select-directory.tsx b/packages/app/src/components/dialog-select-directory.tsx index 2a6c877df1..fc248d821e 100644 --- a/packages/app/src/components/dialog-select-directory.tsx +++ b/packages/app/src/components/dialog-select-directory.tsx @@ -18,6 +18,8 @@ interface DialogSelectDirectoryProps { server: ServerConnection.Any } +const RECENT_PROJECT_LIMIT = 5 + type Row = { absolute: string search: string @@ -57,9 +59,9 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { const [filter, setFilter] = createSignal("") let list: ListRef | undefined - const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory)) + const missingHome = createMemo(() => !sync.data.path.home) const [fallbackPath] = createResource( - () => (missingBase() ? true : undefined), + () => (missingHome() ? true : undefined), async (): Promise => { if ((await sdk.protocol) !== "v1") return return sdk.client.path @@ -102,7 +104,6 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { return projects .map((project, index) => ({ project, at: byProject.get(project.worktree) ?? 0, index })) .sort((a, b) => b.at - a.at || a.index - b.index) - .slice(0, 5) .map(({ project }) => { const row = toRow(project.worktree, home(), "recent") const name = project.name || getFilename(project.worktree) @@ -116,7 +117,10 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { const items = async (value: string) => { const results = await directories(value) const directoryRows = results.map((absolute) => toRow(absolute, home(), "folders")) - return uniqueRows([...recentProjects(), ...directoryRows]) + // Cap the idle list only. Once a query narrows the results, every project stays searchable. + const recent = recentProjects() + const visible = value ? recent : recent.slice(0, RECENT_PROJECT_LIMIT) + return uniqueRows([...visible, ...directoryRows]) } function resolve(absolute: string) { diff --git a/packages/app/src/components/dialog-usage-exceeded.tsx b/packages/app/src/components/dialog-usage-exceeded.tsx index e428d4c2bb..bf5da751e2 100644 --- a/packages/app/src/components/dialog-usage-exceeded.tsx +++ b/packages/app/src/components/dialog-usage-exceeded.tsx @@ -17,7 +17,7 @@ export function DialogUsageExceeded(props: DialogGoUpsellProps) { const platform = usePlatform() const runAction = () => { - if (props.link) platform.openLink(props.link) + if (props.link) platform.openExternal(props.link) props.onClose?.() dialog.close() } diff --git a/packages/app/src/components/external-link.tsx b/packages/app/src/components/external-link.tsx new file mode 100644 index 0000000000..133e752eab --- /dev/null +++ b/packages/app/src/components/external-link.tsx @@ -0,0 +1,21 @@ +import { ComponentProps, splitProps } from "solid-js" + +export interface ExternalLinkProps extends Omit, "href"> { + href: string +} + +export function ExternalLink(props: ExternalLinkProps) { + const [local, rest] = splitProps(props, ["href", "children", "class", "target", "rel"]) + + return ( + + {local.children} + + ) +} diff --git a/packages/app/src/components/link.tsx b/packages/app/src/components/link.tsx deleted file mode 100644 index 85f7efc539..0000000000 --- a/packages/app/src/components/link.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { ComponentProps, splitProps } from "solid-js" -import { usePlatform } from "@/context/platform" - -export interface LinkProps extends Omit, "href"> { - href: string -} - -export function Link(props: LinkProps) { - const platform = usePlatform() - const [local, rest] = splitProps(props, ["href", "children", "class"]) - - return ( - { - if (!local.href) return - event.preventDefault() - platform.openLink(local.href) - }} - {...rest} - > - {local.children} - - ) -} diff --git a/packages/app/src/components/prompt-input-v2.tsx b/packages/app/src/components/prompt-input-v2.tsx index 2d6a5e98d6..96b99aaf4a 100644 --- a/packages/app/src/components/prompt-input-v2.tsx +++ b/packages/app/src/components/prompt-input-v2.tsx @@ -344,7 +344,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): if (item?.commentID) comments.remove(item.path, item.commentID) }, openAttachment: (attachment) => - dialog.show(() => ), + dialog.show(() => ), openContext(key) { const item = controller.contextItem(key) if (item) openComment(item, props, sync, layout, files, comments) @@ -377,6 +377,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): }), readClipboardImage: platform.readClipboardImage, getPathForFile: platform.getPathForFile, + store: platform.draftStore?.putBlob, }, view: { placeholder: designPlaceholder, diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 7923e18ee2..08c1ee3489 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1489,7 +1489,7 @@ export const PromptInput: Component = (props) => { - dialog.show(() => ) + dialog.show(() => ) } onRemove={removeAttachment} removeLabel={language.t("prompt.attachment.remove")} diff --git a/packages/app/src/components/prompt-input/attachments.ts b/packages/app/src/components/prompt-input/attachments.ts index 9568b17d19..6f3ef57c66 100644 --- a/packages/app/src/components/prompt-input/attachments.ts +++ b/packages/app/src/components/prompt-input/attachments.ts @@ -3,28 +3,13 @@ import { makeEventListener } from "@solid-primitives/event-listener" import { showToast } from "@/utils/toast" import { type ContentPart, type ImageAttachmentPart, type usePrompt } from "@/context/prompt" import { useLanguage } from "@/context/language" +import { usePlatform } from "@/context/platform" import { uuid } from "@/utils/uuid" import { getCursorPosition } from "./editor-dom" +import { createBlobReference, type DraftStore } from "@/utils/draft-store" import { attachmentMime } from "./files" import { normalizePaste, pasteMode } from "./paste" -function dataUrl(file: File, mime: string) { - return new Promise((resolve) => { - const reader = new FileReader() - reader.addEventListener("error", () => resolve("")) - reader.addEventListener("load", () => { - const value = typeof reader.result === "string" ? reader.result : "" - const idx = value.indexOf(",") - if (idx === -1) { - resolve(value) - return - } - resolve(`data:${mime};base64,${value.slice(idx + 1)}`) - }) - reader.readAsDataURL(file) - }) -} - type PromptTarget = Pick["capture"]>, "current" | "cursor" | "set"> type AttachmentTarget = { prompt: PromptTarget; cursor: number | undefined } @@ -36,6 +21,7 @@ type PromptAttachmentsCoreInput = { warn?: () => void readClipboardImage?: () => Promise getPathForFile?: (file: File) => string + draftStore?: DraftStore } export type PromptAttachmentsInput = { @@ -65,16 +51,13 @@ export function createPromptAttachmentsCore(input: PromptAttachmentsCoreInput) { return false } - const url = await dataUrl(file, mime) - if (!url) return false - const attachment: ImageAttachmentPart = { type: "image", id: uuid(), filename: file.name, sourcePath: input.getPathForFile?.(file) || undefined, mime, - dataUrl: url, + blob: input.draftStore ? await input.draftStore.putBlob(file) : await createBlobReference(file), } target.prompt.set([...target.prompt.current(), attachment], target.cursor) return true @@ -166,8 +149,10 @@ export function createPromptAttachmentsCore(input: PromptAttachmentsCoreInput) { export function createPromptAttachments(input: PromptAttachmentsInput) { const language = useLanguage() + const platform = usePlatform() const attachments = createPromptAttachmentsCore({ ...input, + draftStore: platform.draftStore, capture: input.prompt.capture, warn: () => { showToast({ diff --git a/packages/app/src/components/prompt-input/build-request-parts.ts b/packages/app/src/components/prompt-input/build-request-parts.ts index 7f0fea2bfe..a1e3448b31 100644 --- a/packages/app/src/components/prompt-input/build-request-parts.ts +++ b/packages/app/src/components/prompt-input/build-request-parts.ts @@ -22,7 +22,7 @@ type ContextFile = { type BuildRequestPartsInput = { prompt: Prompt context: ContextFile[] - images: ImageAttachmentPart[] + images: (Omit & { dataUrl: string })[] text: string messageID: string sessionID: string diff --git a/packages/app/src/components/prompt-input/history-store.ts b/packages/app/src/components/prompt-input/history-store.ts index b64636f3c0..3858b8b9bc 100644 --- a/packages/app/src/components/prompt-input/history-store.ts +++ b/packages/app/src/components/prompt-input/history-store.ts @@ -1,7 +1,13 @@ import { createStore, type SetStoreFunction, type Store } from "solid-js/store" import type { Prompt } from "@/context/prompt" import { Persist, persisted } from "@/utils/persist" -import { prependHistoryEntry, type PromptHistoryComment, type PromptHistoryStoredEntry } from "./history" +import { + clonePromptHistoryComments, + clonePromptParts, + prependHistoryEntry, + type PromptHistoryComment, + type PromptHistoryStoredEntry, +} from "./history" export type PromptInputHistory = { entries: (mode: "normal" | "shell") => PromptHistoryStoredEntry[] @@ -35,13 +41,23 @@ export function createPromptInputHistory(): PromptInputHistory { } export function createPersistedPromptInputHistory() { - const [normal, setNormal] = persisted( - Persist.global("prompt-history", ["prompt-history.v1"]), + const [normal, setNormal, normalInit] = persisted( + Persist.prompt(Persist.global("prompt-history", ["prompt-history.v1"])), createStore({ entries: [] }), ) - const [shell, setShell] = persisted( - Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]), + const [shell, setShell, shellInit] = persisted( + Persist.prompt(Persist.global("prompt-history-shell", ["prompt-history-shell.v1"])), createStore({ entries: [] }), ) - return createPromptInputHistoryStore(normal, setNormal, shell, setShell) + const history = createPromptInputHistoryStore(normal, setNormal, shell, setShell) + return { + ...history, + add(prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) { + const ready = mode === "shell" ? shellInit : normalInit + if (!(ready instanceof Promise)) return history.add(prompt, mode, comments) + const saved = clonePromptParts(prompt) + const metadata = clonePromptHistoryComments(comments) + void ready.then(() => history.add(saved, mode, metadata)) + }, + } } diff --git a/packages/app/src/components/prompt-input/history.test.ts b/packages/app/src/components/prompt-input/history.test.ts index 5e9c2c66ea..94f24ce935 100644 --- a/packages/app/src/components/prompt-input/history.test.ts +++ b/packages/app/src/components/prompt-input/history.test.ts @@ -112,7 +112,7 @@ describe("prompt-input history", () => { end: 12, selection: { startLine: 1, startChar: 1, endLine: 2, endChar: 1 }, }, - { type: "image", id: "1", filename: "img.png", mime: "image/png", dataUrl: "data:image/png;base64,abc" }, + { type: "image", id: "1", filename: "img.png", mime: "image/png", blob: { id: "blob", url: "blob:test" } }, ] const copy = clonePromptParts(original) expect(copy).not.toBe(original) diff --git a/packages/app/src/components/prompt-input/image-attachments.tsx b/packages/app/src/components/prompt-input/image-attachments.tsx index 3a0cc3e4dd..ecc38db888 100644 --- a/packages/app/src/components/prompt-input/image-attachments.tsx +++ b/packages/app/src/components/prompt-input/image-attachments.tsx @@ -100,7 +100,7 @@ export const PromptImageAttachments: Component = (p } > {attachment.filename} props.onOpen(attachment)} diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index 051bf4d06c..c82c3a4399 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -22,6 +22,7 @@ import { ScopedKey } from "@/utils/server-scope" import { createPromptSubmissionState } from "./submission-state" import { normalizeSessionInfo } from "@/utils/session" import { Event } from "@opencode-ai/schema/event" +import { blobDataUrl } from "@/utils/draft-store" type PendingPrompt = { abort: AbortController @@ -95,10 +96,12 @@ export async function sendFollowupDraft(input: FollowupSendInput) { providerID: input.draft.model.providerID, variant: input.draft.variant, }, - files: images.map((attachment) => ({ - uri: attachment.dataUrl, - name: attachment.filename, - })), + files: await Promise.all( + images.map(async (attachment) => ({ + uri: await blobDataUrl(attachment.blob, attachment.mime), + name: attachment.filename, + })), + ), }) return true } catch (err) { @@ -108,10 +111,16 @@ export async function sendFollowupDraft(input: FollowupSendInput) { } const messageID = input.messageID ?? Identifier.ascending("message") + const encodedImages = await Promise.all( + images.map(async (attachment) => ({ + ...attachment, + dataUrl: await blobDataUrl(attachment.blob, attachment.mime), + })), + ) const { requestParts, optimisticParts } = buildRequestParts({ prompt: input.draft.prompt, context: input.draft.context, - images, + images: encodedImages, text, sessionID: input.draft.sessionID, messageID, @@ -516,10 +525,12 @@ export function createPromptSubmit(input: PromptSubmitInput) { arguments: args.join(" "), agent, model: { id: model.modelID, providerID: model.providerID, variant }, - files: images.map((attachment) => ({ - uri: attachment.dataUrl, - name: attachment.filename, - })), + files: await Promise.all( + images.map(async (attachment) => ({ + uri: await blobDataUrl(attachment.blob, attachment.mime), + name: attachment.filename, + })), + ), }) .catch((err) => { serverSync().session.set("session_status", session.id, { type: "idle" }) diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index da1770cd13..dc6cdaa6f3 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -289,9 +289,9 @@ export function SessionHeader() { return ( <> - + {(mount) => ( - +
- + {(keybind) => ( - {keybind()} + {keybind} )} @@ -319,9 +319,9 @@ export function SessionHeader() { )} - + {(mount) => ( - + { description={ <> {language.t("settings.general.row.theme.description")}{" "} - {language.t("common.learnMore")} + {language.t("common.learnMore")} } > diff --git a/packages/app/src/components/settings-v2/general.tsx b/packages/app/src/components/settings-v2/general.tsx index e50bd316c9..b41da107ed 100644 --- a/packages/app/src/components/settings-v2/general.tsx +++ b/packages/app/src/components/settings-v2/general.tsx @@ -9,7 +9,7 @@ import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" import { useUpdaterAction } from "../updater-action" import { useSettings } from "@/context/settings" -import { Link } from "../link" +import { ExternalLink } from "../external-link" import { SettingsListV2 } from "./parts/list" import { SettingsRowV2 } from "./parts/row" import { LayoutRetirementNotice, LayoutTransitionToggle } from "./interface-transition" @@ -150,9 +150,9 @@ const AppearanceSection: Component<{ controller: AppearanceSettingsController }> description={ <> {language.t("settings.general.row.theme.description")}{" "} - + {language.t("common.learnMore")} - + } > diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index 6503503476..7d25e7c649 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -382,7 +382,11 @@ export const Terminal = (props: TerminalProps) => { event.preventDefault() event.stopImmediatePropagation() - platform.openLink(text) + if (URL.canParse(text) && new URL(text).protocol === "file:" && platform.openLocalFile) { + platform.openLocalFile(text) + return + } + platform.openExternal(text) } onMount(() => { diff --git a/packages/app/src/components/titlebar-tab-nav.tsx b/packages/app/src/components/titlebar-tab-nav.tsx index 3823cf10b8..5003d0ed30 100644 --- a/packages/app/src/components/titlebar-tab-nav.tsx +++ b/packages/app/src/components/titlebar-tab-nav.tsx @@ -224,6 +224,7 @@ export function TabNavItem(props: {
+ +