mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-13 04:59:58 -04:00
Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 939c2c2411 | |||
| 56973e0ca4 | |||
| c253d4d311 | |||
| 33a1bd2e90 | |||
| 20197c6b8a | |||
| 230b2f8488 | |||
| a78c8d8972 | |||
| bac3631ded | |||
| 664b0ba2d6 | |||
| 9c8b50b989 | |||
| 4358a02cf4 | |||
| a31058d76c | |||
| 91ba9f2412 | |||
| 3125f67708 | |||
| 550e9cc636 | |||
| af127a643b | |||
| 77aa1cfede | |||
| a2ed936283 | |||
| b17fbf41e3 | |||
| 9d6e05b6e4 | |||
| 9b805c140f | |||
| d31a994c27 | |||
| 76640a5c9c | |||
| 76dbaf20ad | |||
| 9dd0e39867 | |||
| 69a465d33b | |||
| 66c2967520 | |||
| 883c9e3cb4 | |||
| 0d703d39e7 | |||
| bc2251d6d8 | |||
| b034154e09 | |||
| c894f87771 | |||
| fc5c781085 | |||
| f6aa1a67f0 | |||
| 0859f77153 | |||
| 7a22ac865d | |||
| b75dd58f7c | |||
| 56197e621a | |||
| d8f62cfdcb |
@@ -0,0 +1,49 @@
|
||||
name: deploy-lab-catalog
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [v2]
|
||||
paths:
|
||||
- ".github/workflows/deploy-lab-catalog.yml"
|
||||
- "bun.lock"
|
||||
- "package.json"
|
||||
- "packages/drive/**"
|
||||
- "packages/protocol/src/simulation.ts"
|
||||
- "packages/simulation/**"
|
||||
- "packages/lab/catalog/**"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: deploy-lab-catalog-${{ github.ref_name }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
if: github.repository == 'anomalyco/opencode' && github.ref_name == 'v2'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Install ffmpeg
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install --yes ffmpeg
|
||||
|
||||
- name: Validate
|
||||
run: |
|
||||
bun --cwd packages/protocol typecheck
|
||||
bun --cwd packages/simulation typecheck
|
||||
bun --cwd packages/drive run check
|
||||
bun --cwd packages/drive run test
|
||||
bun --cwd packages/lab/catalog run check
|
||||
|
||||
- name: Deploy
|
||||
working-directory: packages/lab/catalog
|
||||
run: bun run deploy
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
@@ -55,6 +55,12 @@ jobs:
|
||||
git config --global user.email "bot@opencode.ai"
|
||||
git config --global user.name "opencode"
|
||||
|
||||
- name: Install ffmpeg
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install --yes ffmpeg
|
||||
|
||||
- name: Cache Turbo
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
@@ -66,7 +72,7 @@ jobs:
|
||||
|
||||
- name: Run unit tests
|
||||
timeout-minutes: 20
|
||||
run: GITHUB_ACTIONS=false bun turbo test
|
||||
run: GITHUB_ACTIONS=false bun turbo test ${{ runner.os == 'Windows' && '--filter=!opencode-drive' || '' }}
|
||||
env:
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ files. If the script is unsuccessful, automatically fix the script and run it ag
|
||||
Scripts use one typed definition object. `setup` runs before OpenCode starts,
|
||||
and `fs.writeFile` always writes inside the simulated project.
|
||||
|
||||
You can read the full typed API here: https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/src/script/types.ts
|
||||
You can read the full typed API here: https://raw.githubusercontent.com/anomalyco/opencode/v2/packages/drive/src/script/types.ts
|
||||
|
||||
```ts
|
||||
import { defineScript } from "opencode-drive"
|
||||
@@ -83,7 +83,7 @@ itself (this is extremely rare, do not use this unless explicitly asked). In thi
|
||||
mode `ui` is typed as `null`; call `server.launch()` exactly
|
||||
once before launching clients. Each `clients.launch(name)` result provides the
|
||||
same UI methods as the automatic client. You can see an example of this API
|
||||
here: https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/examples/multiple-clients.ts
|
||||
here: https://raw.githubusercontent.com/anomalyco/opencode/v2/packages/drive/examples/multiple-clients.ts
|
||||
|
||||
Use the exported `wait(milliseconds)` utility for an unconditional delay.
|
||||
|
||||
@@ -114,8 +114,8 @@ completion are automatic.
|
||||
|
||||
You can see some example scripts here:
|
||||
|
||||
- https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/examples/simple.ts
|
||||
- https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/examples/serve.ts
|
||||
- https://raw.githubusercontent.com/anomalyco/opencode/v2/packages/drive/examples/simple.ts
|
||||
- https://raw.githubusercontent.com/anomalyco/opencode/v2/packages/drive/examples/serve.ts
|
||||
|
||||
## Prune
|
||||
|
||||
|
||||
+9
-7
@@ -33,6 +33,7 @@
|
||||
"packages": [
|
||||
"packages/*",
|
||||
"packages/console/*",
|
||||
"packages/lab/*",
|
||||
"packages/stats/*",
|
||||
"packages/slack"
|
||||
],
|
||||
@@ -46,9 +47,9 @@
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/standard-validator": "0.2.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@opentui/core": "0.0.0-20260808-9ecf7c0a",
|
||||
"@opentui/keymap": "0.0.0-20260808-9ecf7c0a",
|
||||
"@opentui/solid": "0.0.0-20260808-9ecf7c0a",
|
||||
"@opentui/core": "0.5.2",
|
||||
"@opentui/keymap": "0.5.2",
|
||||
"@opentui/solid": "0.5.2",
|
||||
"@tanstack/solid-virtual": "3.13.32",
|
||||
"@shikijs/stream": "4.2.0",
|
||||
"@standard-schema/spec": "1.1.0",
|
||||
@@ -120,7 +121,8 @@
|
||||
"prettier": "3.6.2",
|
||||
"semver": "^7.6.0",
|
||||
"sst": "catalog:",
|
||||
"turbo": "2.10.2"
|
||||
"turbo": "2.10.2",
|
||||
"vitest": "4.1.10"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.933.0",
|
||||
@@ -150,9 +152,9 @@
|
||||
"electron"
|
||||
],
|
||||
"overrides": {
|
||||
"@opentui/core": "0.0.0-20260808-9ecf7c0a",
|
||||
"@opentui/keymap": "0.0.0-20260808-9ecf7c0a",
|
||||
"@opentui/solid": "0.0.0-20260808-9ecf7c0a",
|
||||
"@opentui/core": "catalog:",
|
||||
"@opentui/keymap": "catalog:",
|
||||
"@opentui/solid": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"effect": "catalog:"
|
||||
|
||||
@@ -86,8 +86,8 @@ function TargetServerRoute(props: ParentProps) {
|
||||
return (
|
||||
// Owns the server-identity remount. Session changes must not remount this subtree.
|
||||
<Show when={requireServerKey(params.serverKey)} keyed>
|
||||
<ServerSDKProvider server={conn}>
|
||||
<ServerSyncProvider server={conn}>{props.children}</ServerSyncProvider>
|
||||
<ServerSDKProvider server={conn()}>
|
||||
<ServerSyncProvider server={conn()}>{props.children}</ServerSyncProvider>
|
||||
</ServerSDKProvider>
|
||||
</Show>
|
||||
)
|
||||
@@ -130,16 +130,14 @@ function DraftRoute() {
|
||||
function ResolvedDraftRoute(props: { draft: DraftTab }) {
|
||||
const global = useGlobal()
|
||||
const conn = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === props.draft.server))
|
||||
const directory = () => props.draft.directory
|
||||
const serverKey = () => props.draft.server
|
||||
|
||||
return (
|
||||
<Show when={`${props.draft.server}\0${props.draft.directory}`} keyed>
|
||||
<ServerSDKProvider server={conn}>
|
||||
<ServerSyncProvider server={conn}>
|
||||
<ModelsProvider directory={directory}>
|
||||
<SDKProvider directory={directory}>
|
||||
<DirectoryDataProvider directory={directory} server={serverKey}>
|
||||
<ServerSDKProvider server={conn()}>
|
||||
<ServerSyncProvider server={conn()}>
|
||||
<ModelsProvider directory={props.draft.directory}>
|
||||
<SDKProvider directory={props.draft.directory}>
|
||||
<DirectoryDataProvider directory={props.draft.directory} server={props.draft.server}>
|
||||
<DraftProviders>
|
||||
<NewSession />
|
||||
</DraftProviders>
|
||||
@@ -237,7 +235,7 @@ function DesktopCommands() {
|
||||
}
|
||||
|
||||
type ServerScopedShellProps = ParentProps<{
|
||||
directory?: () => string | undefined
|
||||
directory?: string
|
||||
serverScoped?: JSX.Element
|
||||
}>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useIsRouting, useLocation } from "@solidjs/router"
|
||||
import { batch, createEffect, onCleanup, onMount } from "solid-js"
|
||||
import { batch, createEffect, onCleanup, onMount, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
@@ -571,7 +571,7 @@ export function DebugBar(props: { inline?: boolean } = {}) {
|
||||
value={language.t(`debugBar.direction.${language.direction()}`)}
|
||||
onClick={() => language.setDirection(language.direction() === "rtl" ? "ltr" : "rtl")}
|
||||
/>
|
||||
{platform.setForceFocus && (
|
||||
<Show when={platform.setForceFocus}>
|
||||
<ToggleCell
|
||||
active={state.focus}
|
||||
inline={props.inline}
|
||||
@@ -580,7 +580,7 @@ export function DebugBar(props: { inline?: boolean } = {}) {
|
||||
value={language.t(state.focus ? "debugBar.focus.on" : "debugBar.focus.off")}
|
||||
onClick={() => void toggleFocus()}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
|
||||
@@ -12,7 +12,7 @@ import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
|
||||
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { type Accessor, type Component, createMemo, createUniqueId, For, Match, onMount, Show, Switch } from "solid-js"
|
||||
import { type Component, createMemo, createUniqueId, For, Match, onMount, Show, Switch } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { ExternalLink } from "@/components/external-link"
|
||||
@@ -40,7 +40,7 @@ export function useProviderConnectController(options: { onBack?: () => void } =
|
||||
}
|
||||
|
||||
export const DialogConnectProvider: Component<{
|
||||
directory?: Accessor<string | undefined>
|
||||
directory?: string
|
||||
controller?: ReturnType<typeof useProviderConnectController>
|
||||
}> = (props) => {
|
||||
const fallback = useProviderConnectController()
|
||||
@@ -136,15 +136,11 @@ export const DialogConnectProvider: Component<{
|
||||
)
|
||||
}
|
||||
|
||||
function ProviderPicker(props: {
|
||||
directory?: Accessor<string | undefined>
|
||||
onSelect: (provider: string) => void
|
||||
onPrepare?: () => void
|
||||
}) {
|
||||
function ProviderPicker(props: { directory?: string; onSelect: (provider: string) => void; onPrepare?: () => void }) {
|
||||
const settings = useSettings()
|
||||
if (settings.general.newLayoutDesigns())
|
||||
return <ProviderPickerV2 directory={props.directory} onSelect={props.onSelect} onPrepare={props.onPrepare} />
|
||||
const providers = useProviders(() => props.directory?.())
|
||||
const providers = useProviders(() => props.directory)
|
||||
const language = useLanguage()
|
||||
const popularGroup = () => language.t("dialog.provider.group.popular")
|
||||
const otherGroup = () => language.t("dialog.provider.group.other")
|
||||
@@ -211,12 +207,8 @@ function ProviderPicker(props: {
|
||||
)
|
||||
}
|
||||
|
||||
function ProviderPickerV2(props: {
|
||||
directory?: Accessor<string | undefined>
|
||||
onSelect: (provider: string) => void
|
||||
onPrepare?: () => void
|
||||
}) {
|
||||
const providers = useProviders(() => props.directory?.())
|
||||
function ProviderPickerV2(props: { directory?: string; onSelect: (provider: string) => void; onPrepare?: () => void }) {
|
||||
const providers = useProviders(() => props.directory)
|
||||
const language = useLanguage()
|
||||
const [store, setStore] = createStore({
|
||||
filter: "",
|
||||
@@ -364,7 +356,7 @@ function ProviderPickerV2(props: {
|
||||
|
||||
function ProviderConnection(props: {
|
||||
provider: string
|
||||
directory?: Accessor<string | undefined>
|
||||
directory?: string
|
||||
onBack: () => void
|
||||
setBack: (handler: () => void) => void
|
||||
}) {
|
||||
@@ -374,8 +366,8 @@ function ProviderConnection(props: {
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
const newLayout = settings.general.newLayoutDesigns
|
||||
const providers = useProviders(() => props.directory?.())
|
||||
const directory = () => props.directory?.() ?? decode64(params.dir)
|
||||
const providers = useProviders(() => props.directory)
|
||||
const directory = () => props.directory ?? decode64(params.dir)
|
||||
|
||||
const provider = createMemo(
|
||||
() => providers.all().get(props.provider) ?? serverSync().data.provider.all.get(props.provider)!,
|
||||
|
||||
@@ -31,7 +31,7 @@ export const DialogManageModels: Component = () => {
|
||||
const directory = () => decode64(local.slug())
|
||||
|
||||
const handleConnectProvider = () => {
|
||||
void dialog.show(() => <DialogConnectProvider directory={directory} />)
|
||||
void dialog.show(() => <DialogConnectProvider directory={directory()} />)
|
||||
}
|
||||
const providerRank = (id: string) => popularProviders.indexOf(id)
|
||||
const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID)
|
||||
@@ -123,7 +123,7 @@ export const DialogManageModelsV2: Component = () => {
|
||||
const directory = () => decode64(local.slug())
|
||||
|
||||
const handleConnectProvider = () => {
|
||||
void dialog.show(() => <DialogConnectProvider directory={directory} />)
|
||||
void dialog.show(() => <DialogConnectProvider directory={directory()} />)
|
||||
}
|
||||
const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID)
|
||||
const providerVisible = (providerID: string) =>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createSignal } from "solid-js"
|
||||
import { createSignal, Index, Show } from "solid-js"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
@@ -83,61 +83,71 @@ export function DialogReleaseNotes(props: { highlights: Highlight[] }) {
|
||||
{/* Bottom section - buttons and indicators (fixed position) */}
|
||||
<div class="flex flex-col gap-12">
|
||||
<div class="flex flex-col items-start gap-3">
|
||||
{isLast() ? (
|
||||
<Show
|
||||
when={isLast()}
|
||||
fallback={
|
||||
<Button variant="secondary" size="large" onClick={handleNext}>
|
||||
{language.t("dialog.releaseNotes.action.next")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Button variant="primary" size="large" onClick={handleClose}>
|
||||
{language.t("dialog.releaseNotes.action.getStarted")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="secondary" size="large" onClick={handleNext}>
|
||||
{language.t("dialog.releaseNotes.action.next")}
|
||||
</Button>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<Button variant="ghost" size="small" onClick={handleDisable}>
|
||||
{language.t("dialog.releaseNotes.action.hideFuture")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{paged() && (
|
||||
<Show when={paged()}>
|
||||
<div class="flex items-center gap-1.5 -my-2.5">
|
||||
{props.highlights.map((_, i) => (
|
||||
<button
|
||||
type="button"
|
||||
class="h-6 flex items-center cursor-pointer bg-transparent border-none p-0 transition-all duration-200"
|
||||
classList={{
|
||||
"w-8": i === index(),
|
||||
"w-3": i !== index(),
|
||||
}}
|
||||
onClick={() => setIndex(i)}
|
||||
>
|
||||
<div
|
||||
class="w-full h-0.5 rounded-[1px] transition-colors duration-200"
|
||||
<Index each={props.highlights}>
|
||||
{(_, i) => (
|
||||
<button
|
||||
type="button"
|
||||
class="h-6 flex items-center cursor-pointer bg-transparent border-none p-0 transition-all duration-200"
|
||||
classList={{
|
||||
"bg-icon-strong-base": i === index(),
|
||||
"bg-icon-weak-base": i !== index(),
|
||||
"w-8": i === index(),
|
||||
"w-3": i !== index(),
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
onClick={() => setIndex(i)}
|
||||
>
|
||||
<div
|
||||
class="w-full h-0.5 rounded-[1px] transition-colors duration-200"
|
||||
classList={{
|
||||
"bg-icon-strong-base": i === index(),
|
||||
"bg-icon-weak-base": i !== index(),
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</Index>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right side - Media content (edge to edge) */}
|
||||
{feature()?.media && (
|
||||
<div class="flex-1 min-w-0 bg-surface-base overflow-hidden rounded-r-xl">
|
||||
{feature()!.media!.type === "image" ? (
|
||||
<img
|
||||
src={feature()!.media!.src}
|
||||
alt={feature()!.media!.alt ?? feature()?.title ?? language.t("dialog.releaseNotes.media.alt")}
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<video src={feature()!.media!.src} autoplay loop muted playsinline class="w-full h-full object-cover" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Show when={feature()?.media}>
|
||||
{(media) => (
|
||||
<div class="flex-1 min-w-0 bg-surface-base overflow-hidden rounded-r-xl">
|
||||
<Show
|
||||
when={media().type === "image"}
|
||||
fallback={
|
||||
<video src={media().src} autoplay loop muted playsinline class="w-full h-full object-cover" />
|
||||
}
|
||||
>
|
||||
<img
|
||||
src={media().src}
|
||||
alt={media().alt ?? feature()?.title ?? language.t("dialog.releaseNotes.media.alt")}
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
@@ -103,7 +103,7 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
||||
const base = pickerRoot(cleaned) || root() || start()
|
||||
if (!base) return { query: value, items: directories.slice(0, 5) }
|
||||
const files = await sdk.api.file
|
||||
.get({
|
||||
.find({
|
||||
location: { directory: base },
|
||||
query: pickerFileSearchQuery(base, value, home()),
|
||||
type: "file",
|
||||
|
||||
@@ -37,7 +37,7 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
|
||||
void import("./dialog-connect-provider").then((x) => {
|
||||
const controller = x.useProviderConnectController()
|
||||
controller.select(provider)
|
||||
void dialog.show(() => <x.DialogConnectProvider controller={controller} directory={directory} />)
|
||||
void dialog.show(() => <x.DialogConnectProvider controller={controller} directory={directory()} />)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props
|
||||
void import("./dialog-connect-provider").then((x) => {
|
||||
const controller = x.useProviderConnectController()
|
||||
controller.select(provider)
|
||||
void dialog.show(() => <x.DialogConnectProvider controller={controller} directory={directory} />)
|
||||
void dialog.show(() => <x.DialogConnectProvider controller={controller} directory={directory()} />)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -148,7 +148,7 @@ export function ModelSelectorPopover(props: {
|
||||
const handleConnectProvider = () => {
|
||||
close("provider")
|
||||
void import("./dialog-connect-provider").then((x) => {
|
||||
void dialog.show(() => <x.DialogConnectProvider directory={directory} />)
|
||||
void dialog.show(() => <x.DialogConnectProvider directory={directory()} />)
|
||||
})
|
||||
}
|
||||
const language = useLanguage()
|
||||
@@ -240,7 +240,7 @@ export function ModelSelectorPopoverV2(props: {
|
||||
trigger={props.trigger}
|
||||
models={controller.models}
|
||||
groups={controller.groups}
|
||||
current={controller.current}
|
||||
current={controller.current()}
|
||||
select={controller.select}
|
||||
onManage={() => {
|
||||
void import("./dialog-manage-models").then((module) => {
|
||||
@@ -295,7 +295,7 @@ function ModelSelectorPopoverV2View(props: {
|
||||
trigger: ModelSelectorTrigger
|
||||
models: (search: string) => ModelItem[]
|
||||
groups: (models: ModelItem[]) => { category: string; items: ModelItem[] }[]
|
||||
current: () => string | undefined
|
||||
current: string | undefined
|
||||
select: (item: ModelItem) => void
|
||||
onManage: () => void
|
||||
onClose: () => void
|
||||
@@ -310,7 +310,7 @@ function ModelSelectorPopoverV2View(props: {
|
||||
const groups = createMemo(() => props.groups(models()))
|
||||
const keys = () => [...models().map(modelKey), manageKey]
|
||||
const initialActive = () => {
|
||||
const selected = props.current()
|
||||
const selected = props.current
|
||||
const options = keys()
|
||||
if (selected && options.includes(selected)) return selected
|
||||
return options[0] ?? ""
|
||||
@@ -453,7 +453,7 @@ function ModelSelectorPopoverV2View(props: {
|
||||
<MenuV2.GroupLabel class="gap-2 px-3">
|
||||
<span class="min-w-0 truncate">{group.items[0].provider.name}</span>
|
||||
</MenuV2.GroupLabel>
|
||||
<MenuV2.RadioGroup value={props.current()}>
|
||||
<MenuV2.RadioGroup value={props.current}>
|
||||
<For each={group.items}>
|
||||
{(item) => (
|
||||
<TooltipV2
|
||||
@@ -473,7 +473,7 @@ function ModelSelectorPopoverV2View(props: {
|
||||
<MenuV2.RadioItem
|
||||
value={modelKey(item)}
|
||||
data-option-key={modelKey(item)}
|
||||
data-selected-model={props.current() === modelKey(item) ? true : undefined}
|
||||
data-selected-model={props.current === modelKey(item) ? true : undefined}
|
||||
class="scroll-my-6 w-full"
|
||||
classList={{ "!bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }}
|
||||
onMouseEnter={() => {
|
||||
@@ -529,7 +529,7 @@ export const DialogSelectModel: Component<{ provider?: string; model?: ModelStat
|
||||
|
||||
const provider = () => {
|
||||
void import("./dialog-connect-provider").then((x) => {
|
||||
void dialog.show(() => <x.DialogConnectProvider directory={directory} />)
|
||||
void dialog.show(() => <x.DialogConnectProvider directory={directory()} />)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -135,7 +135,7 @@ test("resolves directory autocomplete from the current browser root", async () =
|
||||
const sdk = {
|
||||
api: {
|
||||
file: {
|
||||
get: (input: { location?: { directory?: string } }) => {
|
||||
find: (input: { location?: { directory?: string } }) => {
|
||||
directories.push(input.location?.directory ?? "")
|
||||
return Promise.resolve({ data: [] })
|
||||
},
|
||||
@@ -157,7 +157,7 @@ test("keeps indexed directory results for servers that support empty search", as
|
||||
const sdk = {
|
||||
api: {
|
||||
file: {
|
||||
get: () => Promise.resolve({ data: [{ path: "projects/", type: "directory" }] }),
|
||||
find: () => Promise.resolve({ data: [{ path: "projects/", type: "directory" }] }),
|
||||
list: () => Promise.reject(new Error("listing should not run when search returns results")),
|
||||
},
|
||||
},
|
||||
@@ -176,7 +176,7 @@ test("lists the default directory when empty search is unsupported", async () =>
|
||||
const sdk = {
|
||||
api: {
|
||||
file: {
|
||||
get: () => Promise.resolve({ data: [] }),
|
||||
find: () => Promise.resolve({ data: [] }),
|
||||
list: (input: { location?: { directory?: string } }) => {
|
||||
calls.push(input.location?.directory ?? "")
|
||||
return Promise.resolve({
|
||||
@@ -198,7 +198,7 @@ test("matches the default directory listing when typed search is unsupported", a
|
||||
const sdk = {
|
||||
api: {
|
||||
file: {
|
||||
get: () => Promise.resolve({ data: [] }),
|
||||
find: () => Promise.resolve({ data: [] }),
|
||||
list: () =>
|
||||
Promise.resolve({
|
||||
data: [
|
||||
|
||||
@@ -375,7 +375,7 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
|
||||
const query = normalizePickerDrive(input.path)
|
||||
if (!pathInput) {
|
||||
const results = await args.sdk.api.file
|
||||
.get({ location: { directory: input.directory }, query, type: "directory", limit: 50 })
|
||||
.find({ location: { directory: input.directory }, query, type: "directory", limit: 50 })
|
||||
.then((result) => result.data.map((entry) => entry.path))
|
||||
.catch(() => [])
|
||||
if (!active()) return []
|
||||
|
||||
@@ -96,13 +96,16 @@ export function ServerRow(props: ServerRowProps) {
|
||||
{(conn) => (
|
||||
<div class="flex flex-row gap-3">
|
||||
<span>
|
||||
{conn().http.username ? (
|
||||
<Show
|
||||
when={conn().http.username}
|
||||
fallback={<span class="text-text-weaker">{language.t("server.row.noUsername")}</span>}
|
||||
>
|
||||
<span class="text-text-weak">{conn().http.username}</span>
|
||||
) : (
|
||||
<span class="text-text-weaker">{language.t("server.row.noUsername")}</span>
|
||||
)}
|
||||
</Show>
|
||||
</span>
|
||||
{conn().http.password && <span class="text-text-weak">••••••••</span>}
|
||||
<Show when={conn().http.password}>
|
||||
<span class="text-text-weak">••••••••</span>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
@@ -12,7 +12,7 @@ import { FileVisual } from "./session-sortable-tab"
|
||||
|
||||
export function SortableTabV2(props: {
|
||||
tab: string
|
||||
index: () => number
|
||||
index: number
|
||||
temporary?: boolean
|
||||
onTabClose: (tab: string) => void
|
||||
onTabDoubleClick?: (tab: string) => void
|
||||
@@ -26,7 +26,7 @@ export function SortableTabV2(props: {
|
||||
return props.tab
|
||||
},
|
||||
get index() {
|
||||
return props.index()
|
||||
return props.index
|
||||
},
|
||||
})
|
||||
const path = createMemo(() => file.pathFromTab(props.tab))
|
||||
|
||||
@@ -14,7 +14,7 @@ import { focusTerminalById } from "@/pages/session/helpers"
|
||||
|
||||
export function SortableTerminalTabV2(props: {
|
||||
terminal: LocalPTY
|
||||
index: () => number
|
||||
index: number
|
||||
newLayout: boolean
|
||||
onClose?: () => void
|
||||
}): JSX.Element {
|
||||
@@ -25,7 +25,7 @@ export function SortableTerminalTabV2(props: {
|
||||
return props.terminal.id
|
||||
},
|
||||
get index() {
|
||||
return props.index()
|
||||
return props.index
|
||||
},
|
||||
})
|
||||
const [store, setStore] = createStore({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Component, For, Show, createMemo, lazy, onCleanup, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
@@ -424,9 +425,9 @@ function SettingsKeybindsV2() {
|
||||
filtered={controller.catalog.filtered}
|
||||
title={controller.catalog.title}
|
||||
keybind={controller.catalog.keybind}
|
||||
active={controller.capture.active}
|
||||
active={controller.capture.active()}
|
||||
onCapture={controller.capture.toggle}
|
||||
hasOverrides={controller.settings.hasOverrides}
|
||||
hasOverrides={controller.settings.hasOverrides()}
|
||||
onReset={controller.settings.reset}
|
||||
/>
|
||||
)
|
||||
@@ -437,9 +438,9 @@ function SettingsKeybindsV2View(props: {
|
||||
filtered: (query: string) => Map<KeybindGroup, string[]>
|
||||
title: (id: string) => string
|
||||
keybind: (id: string) => string
|
||||
active: () => string | null
|
||||
active: string | null
|
||||
onCapture: (id: string) => void
|
||||
hasOverrides: () => boolean
|
||||
hasOverrides: boolean
|
||||
onReset: () => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
@@ -452,7 +453,7 @@ function SettingsKeybindsV2View(props: {
|
||||
<div class="settings-v2-tab-header settings-v2-tab-header--stacked">
|
||||
<div class="settings-v2-tab-header-row">
|
||||
<h2 class="settings-v2-tab-title">{language.t("settings.shortcuts.title")}</h2>
|
||||
<ButtonV2 variant="ghost" onClick={props.onReset} disabled={!props.hasOverrides()}>
|
||||
<ButtonV2 variant="ghost" onClick={props.onReset} disabled={!props.hasOverrides}>
|
||||
{language.t("settings.shortcuts.reset.button")}
|
||||
</ButtonV2>
|
||||
</div>
|
||||
@@ -498,12 +499,12 @@ function SettingsKeybindsV2View(props: {
|
||||
data-keybind-id={id}
|
||||
classList={{
|
||||
"settings-v2-keybind-button": true,
|
||||
"settings-v2-keybind-button--active": props.active() === id,
|
||||
"settings-v2-keybind-button--active": props.active === id,
|
||||
}}
|
||||
onClick={() => props.onCapture(id)}
|
||||
>
|
||||
<Show
|
||||
when={props.active() === id}
|
||||
when={props.active === id}
|
||||
fallback={props.keybind(id) || language.t("settings.shortcuts.unassigned")}
|
||||
>
|
||||
{language.t("settings.shortcuts.pressKeys")}
|
||||
@@ -674,8 +675,6 @@ export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => {
|
||||
</Show>
|
||||
)
|
||||
|
||||
const List = props.v2 ? SettingsListV2 : SettingsList
|
||||
|
||||
const groups = (
|
||||
<div
|
||||
classList={{
|
||||
@@ -700,7 +699,7 @@ export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => {
|
||||
>
|
||||
{language.t(groupKey[group])}
|
||||
</h3>
|
||||
<List>
|
||||
<Dynamic component={props.v2 ? SettingsListV2 : SettingsList}>
|
||||
<For each={filtered().get(group) ?? []}>
|
||||
{(id) => (
|
||||
<div class="flex items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
|
||||
@@ -735,7 +734,7 @@ export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => {
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</List>
|
||||
</Dynamic>
|
||||
</div>
|
||||
</Show>
|
||||
)}
|
||||
|
||||
@@ -103,7 +103,7 @@ export const DialogSettings: Component<{
|
||||
<SettingsServersV2 />
|
||||
</TabsV2.Content>
|
||||
<TabsV2.Content value="providers" class="settings-v2-panel">
|
||||
<SettingsProvidersV2 directory={directory} onBack={showProviders} />
|
||||
<SettingsProvidersV2 directory={directory()} onBack={showProviders} />
|
||||
</TabsV2.Content>
|
||||
<TabsV2.Content value="models" class="settings-v2-panel">
|
||||
<SettingsModelsV2 />
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||
import { createMemo, type Accessor, type Component, For, Show } from "solid-js"
|
||||
import { createMemo, type Component, For, Show } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
@@ -30,14 +30,14 @@ const PROVIDER_NOTES = [
|
||||
const PROVIDER_ICON_SIZE = 16
|
||||
|
||||
export const SettingsProvidersV2: Component<{
|
||||
directory: Accessor<string | undefined>
|
||||
directory: string | undefined
|
||||
onBack?: () => void
|
||||
}> = (props) => {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const serverSdk = useServerSDK()
|
||||
const serverSync = useServerSync()
|
||||
const providers = useProviders(props.directory)
|
||||
const providers = useProviders(() => props.directory)
|
||||
const providerConnect = useProviderConnectController({ onBack: props.onBack })
|
||||
|
||||
const connect = (provider?: string) => {
|
||||
@@ -116,7 +116,7 @@ export const SettingsProvidersV2: Component<{
|
||||
}
|
||||
|
||||
const disconnect = async (providerID: string, name: string) => {
|
||||
const location = props.directory() ? { directory: props.directory() } : undefined
|
||||
const location = props.directory ? { directory: props.directory } : undefined
|
||||
await serverSdk()
|
||||
.api.integration.get({ integrationID: providerID, location })
|
||||
.then(async (integration) => {
|
||||
|
||||
@@ -1,16 +1,7 @@
|
||||
import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import {
|
||||
type Accessor,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createResource,
|
||||
For,
|
||||
type JSXElement,
|
||||
onCleanup,
|
||||
Show,
|
||||
} from "solid-js"
|
||||
import { createEffect, createMemo, createResource, For, type JSXElement, onCleanup, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
@@ -109,7 +100,7 @@ type ServerStatusItem = {
|
||||
onSelect: () => void
|
||||
}
|
||||
|
||||
export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||
export function StatusPopoverBody(props: { shown: boolean }) {
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const global = useGlobal()
|
||||
@@ -125,10 +116,6 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||
})
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!props.shown()) return
|
||||
})
|
||||
|
||||
let dialogRun = 0
|
||||
let dialogDead = false
|
||||
onCleanup(() => {
|
||||
@@ -147,7 +134,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||
const lspItems = createMemo(() => sync().data.lsp ?? [])
|
||||
const lspCount = createMemo(() => lspItems().length)
|
||||
const [pluginList] = createResource(
|
||||
() => (props.shown() ? sdk().directory : undefined),
|
||||
() => (props.shown ? sdk().directory : undefined),
|
||||
(directory) =>
|
||||
sdk()
|
||||
.api.plugin.list({ location: { directory } })
|
||||
|
||||
@@ -74,7 +74,7 @@ export function StatusPopover() {
|
||||
<div class="w-[360px] h-14 rounded-xl bg-background-strong shadow-[var(--shadow-lg-border-base)]" />
|
||||
}
|
||||
>
|
||||
<Body shown={shown} />
|
||||
<Body shown={shown()} />
|
||||
</Suspense>
|
||||
</Show>
|
||||
</Popover>
|
||||
@@ -114,7 +114,7 @@ function DirectoryStatusPopover() {
|
||||
onOpenChange: setShown,
|
||||
body: () => (
|
||||
<StatusPopoverBody shown={shown()}>
|
||||
<Body shown={shown} />
|
||||
<Body shown={shown()} />
|
||||
</StatusPopoverBody>
|
||||
),
|
||||
}))
|
||||
|
||||
@@ -175,6 +175,7 @@ export const Terminal = (props: TerminalProps) => {
|
||||
const settings = useSettings()
|
||||
const theme = useTheme()
|
||||
const language = useLanguage()
|
||||
// Intentional mount-time capture: the imperative xterm/WebSocket lifecycle needs stable values, and Terminal remounts when the SDK scope changes.
|
||||
const directory = sdk().directory
|
||||
const url = sdk().url
|
||||
let container!: HTMLDivElement
|
||||
|
||||
@@ -22,14 +22,14 @@ export function TabNavItem(props: {
|
||||
ref?: Ref<HTMLDivElement>
|
||||
href: string
|
||||
server: ServerConnection.Key
|
||||
session: () => SessionInfo | undefined
|
||||
session: SessionInfo | undefined
|
||||
fallbackTitle?: string
|
||||
onRename: (title: string) => Promise<void>
|
||||
onClose: () => void
|
||||
onNavigate: () => void
|
||||
active?: boolean
|
||||
forceTruncate?: boolean
|
||||
suppressNavigation?: () => boolean
|
||||
suppressNavigation?: boolean
|
||||
dragging?: boolean
|
||||
pressed?: boolean
|
||||
hidden?: boolean
|
||||
@@ -52,22 +52,22 @@ export function TabNavItem(props: {
|
||||
if (conn) return global.ensureServerCtx(conn)
|
||||
})
|
||||
const project = createMemo(() => {
|
||||
const session = props.session()
|
||||
const session = props.session
|
||||
if (!session) return
|
||||
return projectForSession(session, serverCtx()?.projects.list() ?? [])
|
||||
})
|
||||
const title = createMemo(() => {
|
||||
const session = props.session()
|
||||
const session = props.session
|
||||
return session ? sessionLabel(session) : props.fallbackTitle
|
||||
})
|
||||
|
||||
const projectName = createMemo(() => {
|
||||
const session = props.session()
|
||||
const session = props.session
|
||||
if (!session) return
|
||||
return displayName(project() ?? { worktree: session.location.directory })
|
||||
})
|
||||
const previewPath = createMemo(() => {
|
||||
const session = props.session()
|
||||
const session = props.session
|
||||
if (!session) return
|
||||
const home = serverCtx()?.sync.data.path.home
|
||||
return home ? session.location.directory.replace(home, "~") : session.location.directory
|
||||
@@ -80,7 +80,7 @@ export function TabNavItem(props: {
|
||||
})
|
||||
|
||||
const [popoverOpen, setPopoverOpen] = createSignal(false)
|
||||
const previewBlocked = () => !!props.dragging || editing() || !!props.pressed || !props.session()
|
||||
const previewBlocked = () => !!props.dragging || editing() || !!props.pressed || !props.session
|
||||
|
||||
const measureTitleOverflow = () => {
|
||||
if (!titleEl || editing()) {
|
||||
@@ -121,7 +121,7 @@ export function TabNavItem(props: {
|
||||
const closeRename = async (save: boolean) => {
|
||||
if (rename.isPending || !editing()) return
|
||||
|
||||
const original = props.session()?.title ?? ""
|
||||
const original = props.session?.title ?? ""
|
||||
const next = (titleEl.textContent ?? "").trim()
|
||||
|
||||
titleEl.scrollLeft = 0
|
||||
@@ -146,7 +146,7 @@ export function TabNavItem(props: {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (!canOpenTabRename(props.dragging, editing(), rename.isPending)) return
|
||||
const session = props.session()
|
||||
const session = props.session
|
||||
if (!session) return
|
||||
titleEl.textContent = session.title ?? ""
|
||||
setEditing(true)
|
||||
@@ -213,7 +213,7 @@ export function TabNavItem(props: {
|
||||
// Navigate on mousedown to shave the press-release delay off tab switches.
|
||||
if (event.button !== 0) return
|
||||
if (editing()) return
|
||||
if (props.suppressNavigation?.()) return
|
||||
if (props.suppressNavigation) return
|
||||
props.onNavigate()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
@@ -221,14 +221,14 @@ export function TabNavItem(props: {
|
||||
// Mouse navigation already happened on mousedown; detail 0 means keyboard activation.
|
||||
if (event.detail > 0) return
|
||||
if (editing()) return
|
||||
if (props.suppressNavigation?.()) return
|
||||
if (props.suppressNavigation) return
|
||||
props.onNavigate()
|
||||
}}
|
||||
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]"
|
||||
>
|
||||
<span data-slot="project-avatar-slot" class="flex size-4 shrink-0 items-center justify-center">
|
||||
<Show
|
||||
when={props.session()}
|
||||
when={props.session}
|
||||
keyed
|
||||
fallback={
|
||||
<span class="block size-4 rounded-[3px] border border-v2-border-border-muted" aria-hidden="true" />
|
||||
@@ -267,7 +267,7 @@ export function TabNavItem(props: {
|
||||
}
|
||||
if (event.key !== "Escape") return
|
||||
event.preventDefault()
|
||||
titleEl.textContent = props.session()?.title ?? ""
|
||||
titleEl.textContent = props.session?.title ?? ""
|
||||
void closeRename(false)
|
||||
}}
|
||||
onBlur={() => void closeRename(true)}
|
||||
@@ -308,7 +308,7 @@ export function TabNavItem(props: {
|
||||
}}
|
||||
data={{
|
||||
projectName: projectName(),
|
||||
title: props.session()?.title,
|
||||
title: props.session?.title,
|
||||
path: previewPath(),
|
||||
serverName: serverLabel(),
|
||||
}}
|
||||
@@ -323,7 +323,7 @@ export function DraftTabItem(props: {
|
||||
active?: boolean
|
||||
onNavigate: () => void
|
||||
onClose: () => void
|
||||
suppressNavigation?: () => boolean
|
||||
suppressNavigation?: boolean
|
||||
dragging?: boolean
|
||||
pressed?: boolean
|
||||
hidden?: boolean
|
||||
@@ -366,14 +366,14 @@ export function DraftTabItem(props: {
|
||||
onMouseDown={(event) => {
|
||||
// Navigate on mousedown to shave the press-release delay off tab switches.
|
||||
if (event.button !== 0) return
|
||||
if (props.suppressNavigation?.()) return
|
||||
if (props.suppressNavigation) return
|
||||
props.onNavigate()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
// Mouse navigation already happened on mousedown; detail 0 means keyboard activation.
|
||||
if (event.detail > 0) return
|
||||
if (props.suppressNavigation?.()) return
|
||||
if (props.suppressNavigation) return
|
||||
props.onNavigate()
|
||||
}}
|
||||
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base [-webkit-user-drag:none]"
|
||||
|
||||
@@ -24,10 +24,10 @@ import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
function SessionTabSlot(props: {
|
||||
tab: SessionTab
|
||||
id: string
|
||||
index: () => number
|
||||
active: () => boolean
|
||||
index: number
|
||||
active: boolean
|
||||
forceTruncate: boolean
|
||||
session: () => SessionInfo | undefined
|
||||
session: SessionInfo | undefined
|
||||
fallbackTitle?: string
|
||||
onRename: (title: string) => Promise<void>
|
||||
onNavigate: (element: HTMLDivElement) => void
|
||||
@@ -38,7 +38,7 @@ function SessionTabSlot(props: {
|
||||
return props.id
|
||||
},
|
||||
get index() {
|
||||
return props.index()
|
||||
return props.index
|
||||
},
|
||||
})
|
||||
let ref!: HTMLDivElement
|
||||
@@ -48,7 +48,7 @@ function SessionTabSlot(props: {
|
||||
ref={sortable.ref}
|
||||
data-titlebar-tab-slot
|
||||
data-tab-key={props.id}
|
||||
data-active={props.active()}
|
||||
data-active={props.active}
|
||||
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
|
||||
>
|
||||
<TabNavItem
|
||||
@@ -62,7 +62,7 @@ function SessionTabSlot(props: {
|
||||
onRename={props.onRename}
|
||||
onNavigate={() => props.onNavigate(ref)}
|
||||
onClose={props.onClose}
|
||||
active={props.active()}
|
||||
active={props.active}
|
||||
forceTruncate={props.forceTruncate}
|
||||
dragging={sortable.isDragSource()}
|
||||
/>
|
||||
@@ -73,34 +73,34 @@ function SessionTabSlot(props: {
|
||||
function SessionTabEntry(props: {
|
||||
tab: SessionTab
|
||||
id: string
|
||||
index: () => number
|
||||
active: () => boolean
|
||||
index: number
|
||||
active: boolean
|
||||
forceTruncate: boolean
|
||||
serverCtx: () => ServerCtx | undefined
|
||||
serverCtx: ServerCtx | undefined
|
||||
onVisibleChange: (visible: boolean) => void
|
||||
onNavigate: (element: HTMLDivElement) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const tabs = useTabs()
|
||||
const language = useLanguage()
|
||||
const sdk = createMemo(() => props.serverCtx()?.sdk ?? null)
|
||||
const cachedSession = createMemo(() => props.serverCtx()?.sync.session.peek(props.tab.sessionId))
|
||||
const sdk = createMemo(() => props.serverCtx?.sdk ?? null)
|
||||
const cachedSession = createMemo(() => props.serverCtx?.sync.session.peek(props.tab.sessionId))
|
||||
const persisted = createMemo(() => tabs.info[props.id])
|
||||
const [loadedSession] = createResource(
|
||||
() => {
|
||||
const ctx = props.serverCtx()
|
||||
const ctx = props.serverCtx
|
||||
return ctx ? { id: props.tab.sessionId, ctx } : null
|
||||
},
|
||||
({ id, ctx }) => ctx.sync.session.resolve(id).catch(() => undefined),
|
||||
)
|
||||
const session = createMemo(() => cachedSession() ?? loadedSession())
|
||||
const missingSession = createMemo(() => !!props.serverCtx() && !loadedSession.loading && !session())
|
||||
const missingSession = createMemo(() => !!props.serverCtx && !loadedSession.loading && !session())
|
||||
const visible = createMemo(() => !!session() || missingSession() || !!persisted()?.title)
|
||||
let prefetched = false
|
||||
|
||||
const rename = async (title: string) => {
|
||||
const value = session()
|
||||
const ctx = props.serverCtx()
|
||||
const ctx = props.serverCtx
|
||||
if (!value || !ctx) return
|
||||
|
||||
ctx.sync.session.remember({ ...value, title })
|
||||
@@ -108,7 +108,7 @@ function SessionTabEntry(props: {
|
||||
await ctx.sdk.api.session.rename({ sessionID: value.id, title })
|
||||
} catch (err) {
|
||||
const current = session()
|
||||
const currentCtx = props.serverCtx()
|
||||
const currentCtx = props.serverCtx
|
||||
if (current && currentCtx) currentCtx.sync.session.remember({ ...current, title: value.title })
|
||||
showToast({
|
||||
title: language.t("common.requestFailed"),
|
||||
@@ -120,7 +120,7 @@ function SessionTabEntry(props: {
|
||||
createEffect(() => props.onVisibleChange(visible()))
|
||||
|
||||
createEffect(() => {
|
||||
const ctx = props.serverCtx()
|
||||
const ctx = props.serverCtx
|
||||
const value = session()
|
||||
if (!ctx || !value || prefetched) return
|
||||
prefetched = true
|
||||
@@ -157,7 +157,7 @@ function SessionTabEntry(props: {
|
||||
index={props.index}
|
||||
active={props.active}
|
||||
forceTruncate={props.forceTruncate}
|
||||
session={session}
|
||||
session={session()}
|
||||
fallbackTitle={persisted()?.title ?? (missingSession() ? language.t("session.tab.unknown") : undefined)}
|
||||
onRename={rename}
|
||||
onNavigate={props.onNavigate}
|
||||
@@ -170,8 +170,8 @@ function SessionTabEntry(props: {
|
||||
function DraftTabSlot(props: {
|
||||
tab: Extract<Tab, { type: "draft" }>
|
||||
id: string
|
||||
index: () => number
|
||||
active: () => boolean
|
||||
index: number
|
||||
active: boolean
|
||||
title: string
|
||||
onNavigate: (element: HTMLDivElement) => void
|
||||
onClose: () => void
|
||||
@@ -181,7 +181,7 @@ function DraftTabSlot(props: {
|
||||
return props.id
|
||||
},
|
||||
get index() {
|
||||
return props.index()
|
||||
return props.index
|
||||
},
|
||||
})
|
||||
let ref!: HTMLDivElement
|
||||
@@ -191,7 +191,7 @@ function DraftTabSlot(props: {
|
||||
ref={sortable.ref}
|
||||
data-titlebar-tab-slot
|
||||
data-tab-key={props.id}
|
||||
data-active={props.active()}
|
||||
data-active={props.active}
|
||||
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
|
||||
>
|
||||
<DraftTabItem
|
||||
@@ -202,7 +202,7 @@ function DraftTabSlot(props: {
|
||||
title={props.title}
|
||||
onNavigate={() => props.onNavigate(ref)}
|
||||
onClose={props.onClose}
|
||||
active={props.active()}
|
||||
active={props.active}
|
||||
dragging={sortable.isDragSource()}
|
||||
/>
|
||||
</div>
|
||||
@@ -211,7 +211,7 @@ function DraftTabSlot(props: {
|
||||
|
||||
export function TitlebarTabStrip(props: {
|
||||
tabs: Tab[]
|
||||
currentTab: () => Tab | undefined
|
||||
currentTab: Tab | undefined
|
||||
forceTruncate: boolean
|
||||
onNavigate: (tab: Tab, el?: HTMLDivElement) => void
|
||||
onClose: (tab: Tab) => void
|
||||
@@ -248,7 +248,7 @@ export function TitlebarTabStrip(props: {
|
||||
])
|
||||
|
||||
function selectAdjacentTab(offset: -1 | 1) {
|
||||
const current = props.currentTab()
|
||||
const current = props.currentTab
|
||||
const key = adjacentTabKey(visibleTabIds(), current ? tabKey(current) : undefined, offset)
|
||||
const next = props.tabs.find((tab) => tabKey(tab) === key)
|
||||
if (next) props.onNavigate(next)
|
||||
@@ -350,10 +350,10 @@ export function TitlebarTabStrip(props: {
|
||||
<SessionTabEntry
|
||||
tab={tab}
|
||||
id={id}
|
||||
index={visibleIndex}
|
||||
active={() => props.currentTab() === tab}
|
||||
index={visibleIndex()}
|
||||
active={props.currentTab === tab}
|
||||
forceTruncate={props.forceTruncate}
|
||||
serverCtx={serverCtx}
|
||||
serverCtx={serverCtx()}
|
||||
onVisibleChange={(visible) => setVisibility(id, visible)}
|
||||
onNavigate={(element) => {
|
||||
ref = element
|
||||
@@ -368,8 +368,8 @@ export function TitlebarTabStrip(props: {
|
||||
<DraftTabSlot
|
||||
tab={tab}
|
||||
id={id}
|
||||
index={visibleIndex}
|
||||
active={() => props.currentTab() === tab}
|
||||
index={visibleIndex()}
|
||||
active={props.currentTab === tab}
|
||||
title={language.t("command.session.new")}
|
||||
onNavigate={(element) => {
|
||||
ref = element
|
||||
|
||||
@@ -46,8 +46,8 @@ const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px
|
||||
const macTrafficLightsBaseWidth = 84
|
||||
|
||||
export type TitlebarUpdate = {
|
||||
version: () => string | undefined
|
||||
installing: () => boolean
|
||||
version: string | undefined
|
||||
installing: boolean
|
||||
install: () => void
|
||||
}
|
||||
|
||||
@@ -121,8 +121,8 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
||||
const hasProjects = createMemo(() => layout.projects.list().length > 0)
|
||||
const nav = createMemo(() => (useV2Titlebar() ? settings.general.showNavigation() : true))
|
||||
const updateState = createMemo<TitlebarUpdatePillState>(() => {
|
||||
const installing = props.update?.installing() ?? false
|
||||
const version = props.update?.version()
|
||||
const installing = props.update?.installing ?? false
|
||||
const version = props.update?.version
|
||||
return {
|
||||
visible: version !== undefined || installing,
|
||||
installing,
|
||||
@@ -392,7 +392,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
||||
|
||||
<TitlebarTabStrip
|
||||
tabs={tabsStore}
|
||||
currentTab={currentTab}
|
||||
currentTab={currentTab()}
|
||||
forceTruncate={tabsAreOverflowing()}
|
||||
onOverflowChange={setTabsAreOverflowing}
|
||||
onNavigate={(tab, el) => {
|
||||
@@ -657,12 +657,10 @@ function ChannelIndicator(props: { debugTools?: { visible: boolean; toggle: () =
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{["local", "beta", "dev"].includes(channel) && (
|
||||
<div class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono">
|
||||
{channel.toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
<Show when={["local", "beta", "dev"].includes(channel)}>
|
||||
<div class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono">
|
||||
{channel.toUpperCase()}
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Show, type JSX } from "solid-js"
|
||||
import { For, 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"
|
||||
@@ -50,7 +50,20 @@ export function WindowsAppMenu(props: {
|
||||
|
||||
return (
|
||||
<DropdownMenu gutter={4} modal={false} placement="bottom-start">
|
||||
{props.variant === "v2" ? (
|
||||
<Show
|
||||
when={props.variant === "v2"}
|
||||
fallback={
|
||||
<DropdownMenu.Trigger
|
||||
as={IconButton}
|
||||
icon="menu"
|
||||
variant="ghost"
|
||||
class="titlebar-icon rounded-md shrink-0"
|
||||
aria-label={language.t("desktop.menu.ariaLabel")}
|
||||
onPointerDown={rememberFocus}
|
||||
onKeyDown={rememberFocus}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div
|
||||
data-component="desktop-icon-button"
|
||||
class="flex h-7 w-9 shrink-0 items-center justify-center rounded-[6px] px-1"
|
||||
@@ -65,39 +78,31 @@ export function WindowsAppMenu(props: {
|
||||
onKeyDown={rememberFocus}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<DropdownMenu.Trigger
|
||||
as={IconButton}
|
||||
icon="menu"
|
||||
variant="ghost"
|
||||
class="titlebar-icon rounded-md shrink-0"
|
||||
aria-label={language.t("desktop.menu.ariaLabel")}
|
||||
onPointerDown={rememberFocus}
|
||||
onKeyDown={rememberFocus}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content class="desktop-app-menu">
|
||||
<DropdownMenu.Group>
|
||||
<DropdownMenu.GroupLabel class="desktop-app-menu-heading">OpenCode</DropdownMenu.GroupLabel>
|
||||
{DESKTOP_MENU.filter((menu) => desktopMenuVisible(menu, "windows")).map((menu) => (
|
||||
<DesktopMenuSubmenu label={language.t(menu.labelKey)}>
|
||||
{menu.items
|
||||
?.filter((entry) => desktopMenuVisible(entry, "windows"))
|
||||
.map((entry) =>
|
||||
entry.type === "separator" ? (
|
||||
<DropdownMenu.Separator />
|
||||
) : (
|
||||
<DesktopMenuItem
|
||||
label={entry.labelKey ? language.t(entry.labelKey) : ""}
|
||||
keybind={entry.command ? props.command.keybind(entry.command) : entry.accelerator?.windows}
|
||||
disabled={entry.command ? commandDisabled(entry.command) : false}
|
||||
onSelect={() => runEntry(entry)}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</DesktopMenuSubmenu>
|
||||
))}
|
||||
<For each={DESKTOP_MENU.filter((menu) => desktopMenuVisible(menu, "windows"))}>
|
||||
{(menu) => (
|
||||
<DesktopMenuSubmenu label={language.t(menu.labelKey)}>
|
||||
<For each={menu.items?.filter((entry) => desktopMenuVisible(entry, "windows"))}>
|
||||
{(entry) => {
|
||||
// Static menu data: an early return keeps the union narrowing a Show fallback would lose.
|
||||
if (entry.type === "separator") return <DropdownMenu.Separator />
|
||||
return (
|
||||
<DesktopMenuItem
|
||||
label={entry.labelKey ? language.t(entry.labelKey) : ""}
|
||||
keybind={entry.command ? props.command.keybind(entry.command) : entry.accelerator?.windows}
|
||||
disabled={entry.command ? commandDisabled(entry.command) : false}
|
||||
onSelect={() => runEntry(entry)}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</DesktopMenuSubmenu>
|
||||
)}
|
||||
</For>
|
||||
</DropdownMenu.Group>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
|
||||
@@ -212,7 +212,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||
|
||||
const search = (query: string, dirs: "true" | "false", options?: { limit?: number; signal?: AbortSignal }) =>
|
||||
serverSDK()
|
||||
.api.file.get(
|
||||
.api.file.find(
|
||||
{
|
||||
location: { directory: sdk().directory },
|
||||
query,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Binary } from "@opencode-ai/core/util/binary"
|
||||
import { ProjectDirectories } from "@opencode-ai/schema/project-directories"
|
||||
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import type { Message, Part, Project, Todo } from "@/types"
|
||||
import type {
|
||||
@@ -187,6 +188,18 @@ export function applyDirectoryEvent(input: {
|
||||
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
|
||||
break
|
||||
}
|
||||
case "project.directory.resolved": {
|
||||
const properties = event.properties as { projectID: string; directory: string; previous: string }
|
||||
input.store.session.forEach((session, index) => {
|
||||
const adopted = ProjectDirectories.adopt(
|
||||
{ projectID: session.projectID, directory: session.location.directory },
|
||||
properties,
|
||||
)
|
||||
if (!adopted) return
|
||||
input.setStore("session", index, (current) => ({ ...current, ...adopted }))
|
||||
})
|
||||
break
|
||||
}
|
||||
case "session.renamed": {
|
||||
const properties = event.properties as { sessionID: string; title: string }
|
||||
const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type Accessor, createMemo, createResource } from "solid-js"
|
||||
import { createMemo, createResource } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { DateTime } from "luxon"
|
||||
import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda"
|
||||
@@ -25,8 +25,8 @@ function modelKey(model: ModelKey) {
|
||||
export const { use: useModels, provider: ModelsProvider } = createSimpleContext({
|
||||
name: "Models",
|
||||
gate: false,
|
||||
init: (props: { directory?: Accessor<string | undefined> } = {}) => {
|
||||
const providers = useProviders(() => props.directory?.())
|
||||
init: (props: { directory?: string } = {}) => {
|
||||
const providers = useProviders(() => props.directory)
|
||||
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
Persist.global("model", ["model.v1"]),
|
||||
|
||||
@@ -6,12 +6,9 @@ export type { DirectorySDK } from "./server-sdk"
|
||||
const context = createSimpleContext({
|
||||
name: "SDK",
|
||||
// Resolves the directory-scoped SDK reactively from the (possibly changing) server.
|
||||
init: (props: { directory: string | Accessor<string> }) => {
|
||||
init: (props: { directory: string }) => {
|
||||
const serverSDK = useServerSDK()
|
||||
return createMemo(() => {
|
||||
const directory = typeof props.directory === "function" ? props.directory() : props.directory
|
||||
return serverSDK().ensureDirSdkContext(directory)
|
||||
})
|
||||
return createMemo(() => serverSDK().ensureDirSdkContext(props.directory))
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Event } from "@/types"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { type Accessor, batch, createMemo, onCleanup, onMount } from "solid-js"
|
||||
import { batch, createMemo, onCleanup, onMount } from "solid-js"
|
||||
import { createApiForServer, type ServerApi } from "@/utils/server"
|
||||
import { useLanguage } from "./language"
|
||||
import { usePlatform } from "./platform"
|
||||
@@ -270,13 +270,13 @@ export const { use: useServerSDK, provider: ServerSDKProvider } = createSimpleCo
|
||||
name: "ServerSDK",
|
||||
// Returns an accessor so the resolved server can change reactively (e.g. a
|
||||
// /new-session draft retargeting its server) without re-instantiating the subtree.
|
||||
init: (props: { server?: Accessor<ServerConnection.Any | undefined> }) => {
|
||||
init: (props: { server?: ServerConnection.Any }) => {
|
||||
const global = useGlobal()
|
||||
const language = useLanguage()
|
||||
const server = useServer()
|
||||
|
||||
return createMemo<ServerSDK>(() => {
|
||||
const conn = props.server?.() ?? server.current
|
||||
const conn = props.server ?? server.current
|
||||
if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable"))
|
||||
return global.ensureServerCtx(conn).sdk
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Binary } from "@opencode-ai/core/util/binary"
|
||||
import { ProjectDirectories } from "@opencode-ai/schema/project-directories"
|
||||
import { retry } from "@opencode-ai/core/util/retry"
|
||||
import type { OpenCodeEvent, SessionApi, SessionInfo, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import type { Message, Part, Todo } from "@/types"
|
||||
@@ -894,6 +895,17 @@ export function createServerSession(
|
||||
}
|
||||
|
||||
const applyV2 = (event: OpenCodeEvent) => {
|
||||
if (event.type === "project.directory.resolved") {
|
||||
Object.values(data.info).forEach((info) => {
|
||||
if (!info) return
|
||||
const adopted = ProjectDirectories.adopt(
|
||||
{ projectID: info.projectID, directory: info.location.directory },
|
||||
event.data,
|
||||
)
|
||||
if (adopted) remember({ ...info, ...adopted })
|
||||
})
|
||||
return
|
||||
}
|
||||
if (!("data" in event) || !("sessionID" in event.data) || typeof event.data.sessionID !== "string") return
|
||||
const sessionID = event.data.sessionID
|
||||
const reduction = v2.reduce(data.session_message[sessionID] ?? [], event)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Config, Path, Project, ProviderAuthResponse } from "@/types"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { type Accessor, batch, createMemo, getOwner, onCleanup, onMount, untrack } from "solid-js"
|
||||
import { batch, createMemo, getOwner, onCleanup, onMount, untrack } from "solid-js"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import type { InitError } from "../pages/error"
|
||||
@@ -699,13 +699,13 @@ export const { use: useServerSync, provider: ServerSyncProvider } = createSimple
|
||||
name: "ServerSync",
|
||||
// Returns an accessor so the resolved server can change reactively without
|
||||
// re-instantiating the subtree (mirrors useServerSDK).
|
||||
init: (props: { server?: Accessor<ServerConnection.Any | undefined> }) => {
|
||||
init: (props: { server?: ServerConnection.Any }) => {
|
||||
const global = useGlobal()
|
||||
const language = useLanguage()
|
||||
const server = useServer()
|
||||
|
||||
return createMemo<ServerSync>(() => {
|
||||
const conn = props.server?.() ?? server.current
|
||||
const conn = props.server ?? server.current
|
||||
if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable"))
|
||||
return global.ensureServerCtx(conn).sync
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ import { DataProvider } from "@opencode-ai/session-ui/context"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { useLocation, useNavigate, useParams } from "@solidjs/router"
|
||||
import { type Accessor, createEffect, createMemo, createResource, onCleanup, type ParentProps, Show } from "solid-js"
|
||||
import { createEffect, createMemo, createResource, onCleanup, type ParentProps, Show } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { LocalProvider } from "@/context/local"
|
||||
import { SDKProvider } from "@/context/sdk"
|
||||
@@ -15,9 +15,9 @@ import { useServerSync } from "@/context/server-sync"
|
||||
|
||||
export function DirectoryDataProvider(
|
||||
props: ParentProps<{
|
||||
directory: string | Accessor<string>
|
||||
directory: string
|
||||
draftID?: string
|
||||
server?: Accessor<ServerConnection.Key | undefined>
|
||||
server?: ServerConnection.Key
|
||||
}>,
|
||||
) {
|
||||
const location = useLocation()
|
||||
@@ -25,17 +25,16 @@ export function DirectoryDataProvider(
|
||||
const params = useParams()
|
||||
const sync = useSync()
|
||||
const serverSync = useServerSync()
|
||||
const directory = () => (typeof props.directory === "function" ? props.directory() : props.directory)
|
||||
const directory = () => props.directory
|
||||
const slug = createMemo(() => base64Encode(directory()))
|
||||
const href = (sessionID: string) => {
|
||||
const server = props.server?.()
|
||||
if (server) return sessionHref(server, sessionID)
|
||||
if (props.server) return sessionHref(props.server, sessionID)
|
||||
return `/${slug()}/session/${sessionID}`
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
// A draft lives at /new-session?draftId=… and has no directory segment to normalize.
|
||||
if (props.draftID || props.server?.()) return
|
||||
if (props.draftID || props.server) return
|
||||
const next = sync().data.path.directory
|
||||
if (!next || next === directory()) return
|
||||
const path = location.pathname.slice(slug().length + 1)
|
||||
|
||||
@@ -23,8 +23,8 @@ export function Home() {
|
||||
>
|
||||
<ScrollView
|
||||
class="h-full [container-type:size]"
|
||||
thumbContainer={scroll.viewport.thumbTrack}
|
||||
thumbHoverTarget={scroll.viewport.hoverTarget}
|
||||
thumbContainer={scroll.viewport.thumbTrack()}
|
||||
thumbHoverTarget={scroll.viewport.hoverTarget()}
|
||||
viewportRef={scroll.viewport.setViewport}
|
||||
onScroll={(event) => scroll.viewport.update(event.currentTarget.scrollTop)}
|
||||
onWheel={scroll.viewport.containOuterWheel}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type Accessor, createMemo, For, type JSX, onCleanup, Show, splitProps } from "solid-js"
|
||||
import { createMemo, For, type JSX, onCleanup, Show, splitProps } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
|
||||
import { isSortable, useSortable } from "@dnd-kit/solid/sortable"
|
||||
@@ -29,16 +29,16 @@ const projectContextMenuID = (server: ServerConnection.Any, directory: string) =
|
||||
|
||||
export type HomeProjectsViewProps = {
|
||||
language: ReturnType<typeof useLanguage>
|
||||
servers: Accessor<ServerConnection.Any[]>
|
||||
projects: Accessor<LocalProject[]>
|
||||
recentlyClosed: Accessor<LocalProject[]>
|
||||
selection: Accessor<HomeProjectSelection>
|
||||
homedir: Accessor<string>
|
||||
servers: ServerConnection.Any[]
|
||||
projects: LocalProject[]
|
||||
recentlyClosed: LocalProject[]
|
||||
selection: HomeProjectSelection
|
||||
homedir: string
|
||||
serverHealth: (server: ServerConnection.Any) => ServerHealth | undefined
|
||||
projectsForServer: (server: ServerConnection.Any) => LocalProject[]
|
||||
collapsed: (server: ServerConnection.Any) => boolean
|
||||
canDefaultServer: Accessor<boolean>
|
||||
defaultServerKey: Accessor<ServerConnection.Key | null | undefined>
|
||||
canDefaultServer: boolean
|
||||
defaultServerKey: ServerConnection.Key | null | undefined
|
||||
canRevealProject: (server: ServerConnection.Any) => boolean
|
||||
unseenCount: (server: ServerConnection.Any, project: LocalProject) => number
|
||||
onWheel: (event: WheelEvent) => void
|
||||
@@ -81,9 +81,7 @@ export function HomeProjectsView(props: HomeProjectsViewProps) {
|
||||
>
|
||||
<div class="flex h-7 min-w-0 shrink-0 items-center justify-between pl-1.5 pr-3">
|
||||
<div class="text-v2-text-text-muted [font-weight:530]">{props.language.t("home.projects")}</div>
|
||||
<Show
|
||||
when={props.servers().length === 1 && !(props.projects().length === 0 && props.recentlyClosed().length > 0)}
|
||||
>
|
||||
<Show when={props.servers.length === 1 && !(props.projects.length === 0 && props.recentlyClosed.length > 0)}>
|
||||
<TooltipV2 placement="bottom" value={props.language.t("home.project.add")}>
|
||||
<IconButtonV2
|
||||
data-action="home-add-project"
|
||||
@@ -91,8 +89,8 @@ export function HomeProjectsView(props: HomeProjectsViewProps) {
|
||||
size="large"
|
||||
class="titlebar-icon [&_[data-slot=icon-svg]]:text-v2-icon-icon-muted"
|
||||
icon={<IconV2 name="folder-add-left" />}
|
||||
disabled={props.serverHealth(props.servers()[0])?.healthy === false}
|
||||
onClick={() => props.onChooseProject(props.servers()[0])}
|
||||
disabled={props.serverHealth(props.servers[0])?.healthy === false}
|
||||
onClick={() => props.onChooseProject(props.servers[0])}
|
||||
aria-label={props.language.t("home.project.add")}
|
||||
/>
|
||||
</TooltipV2>
|
||||
@@ -100,25 +98,20 @@ export function HomeProjectsView(props: HomeProjectsViewProps) {
|
||||
</div>
|
||||
<ScrollView data-slot="home-projects-scroll" class="min-h-0 min-w-0 shrink">
|
||||
<Show
|
||||
when={props.servers().length > 1}
|
||||
when={props.servers.length > 1}
|
||||
fallback={
|
||||
<div class="pr-3">
|
||||
<Show
|
||||
when={props.projects().length > 0}
|
||||
fallback={<HomeProjectEmpty {...props} server={props.servers()[0]} items={props.recentlyClosed()} />}
|
||||
when={props.projects.length > 0}
|
||||
fallback={<HomeProjectEmpty {...props} server={props.servers[0]} items={props.recentlyClosed} />}
|
||||
>
|
||||
<HomeProjectList
|
||||
{...props}
|
||||
{...contextMenuProps}
|
||||
server={props.servers()[0]}
|
||||
items={props.projects()}
|
||||
/>
|
||||
<HomeProjectList {...props} {...contextMenuProps} server={props.servers[0]} items={props.projects} />
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="flex min-w-0 flex-col gap-4 pr-3">
|
||||
<For each={props.servers()}>
|
||||
<For each={props.servers}>
|
||||
{(item) => {
|
||||
const projects = () => props.projectsForServer(item)
|
||||
const healthy = () => !!props.serverHealth(item)?.healthy
|
||||
@@ -130,7 +123,7 @@ export function HomeProjectsView(props: HomeProjectsViewProps) {
|
||||
server={item}
|
||||
{...props}
|
||||
{...contextMenuProps}
|
||||
selected={props.selection().server === ServerConnection.key(item) && !props.selection().directory}
|
||||
selected={props.selection.server === ServerConnection.key(item) && !props.selection.directory}
|
||||
collapsed={collapsed()}
|
||||
health={props.serverHealth(item)}
|
||||
/>
|
||||
@@ -277,8 +270,8 @@ function HomeServerRow(props: {
|
||||
<ServerRowMenuView
|
||||
server={props.server}
|
||||
labels={serverMenuLabels(props.language)}
|
||||
canDefault={props.canDefaultServer()}
|
||||
isDefault={props.defaultServerKey() === ServerConnection.key(props.server)}
|
||||
canDefault={props.canDefaultServer}
|
||||
isDefault={props.defaultServerKey === ServerConnection.key(props.server)}
|
||||
canRemove={props.canRemoveServer(props.server)}
|
||||
onEdit={props.onEditServer}
|
||||
onSetDefault={() => props.onSetDefaultServer(props.server)}
|
||||
@@ -339,7 +332,7 @@ function HomeProjectList(props: HomeProjectListProps) {
|
||||
const source = event.operation.source
|
||||
if (event.canceled || !isSortable(source)) return
|
||||
if (source.initialIndex !== source.index) props.onMoveProject(props.server, source.id.toString(), source.index)
|
||||
if (props.selection().server !== ServerConnection.key(props.server))
|
||||
if (props.selection.server !== ServerConnection.key(props.server))
|
||||
props.onSelectProject(props.server, source.id.toString())
|
||||
}}
|
||||
>
|
||||
@@ -350,7 +343,7 @@ function HomeProjectList(props: HomeProjectListProps) {
|
||||
row's sortable unregisters on unmount) and discarding animations.
|
||||
String keys keep row elements alive and move them on reorder. */}
|
||||
<For each={props.items.map((project) => project.worktree)}>
|
||||
{(worktree, index) => <HomeProjectSlot {...props} worktree={worktree} index={index} />}
|
||||
{(worktree, index) => <HomeProjectSlot {...props} worktree={worktree} index={index()} />}
|
||||
</For>
|
||||
</div>
|
||||
</DragDropProvider>
|
||||
@@ -360,7 +353,7 @@ function HomeProjectList(props: HomeProjectListProps) {
|
||||
function HomeProjectSlot(
|
||||
props: HomeProjectListProps & {
|
||||
worktree: string
|
||||
index: () => number
|
||||
index: number
|
||||
},
|
||||
) {
|
||||
const initial = props.items.find((item) => item.worktree === props.worktree)
|
||||
@@ -376,10 +369,9 @@ function HomeProjectSlot(
|
||||
project={project()}
|
||||
server={props.server}
|
||||
index={props.index}
|
||||
serverSelected={props.selection().server === ServerConnection.key(props.server)}
|
||||
serverSelected={props.selection.server === ServerConnection.key(props.server)}
|
||||
selected={
|
||||
props.selection().server === ServerConnection.key(props.server) &&
|
||||
props.selection().directory === props.worktree
|
||||
props.selection.server === ServerConnection.key(props.server) && props.selection.directory === props.worktree
|
||||
}
|
||||
unseen={props.unseenCount(props.server, project())}
|
||||
/>
|
||||
@@ -425,7 +417,7 @@ function HomeRecentlyClosedRow(
|
||||
) {
|
||||
const unreachable = () => props.serverHealth(props.server)?.healthy === false
|
||||
const path = () => {
|
||||
const home = props.homedir()
|
||||
const home = props.homedir
|
||||
const worktree = props.project.worktree
|
||||
if (home && (worktree === home || worktree.startsWith(`${home}/`))) return `~${worktree.slice(home.length)}`
|
||||
return worktree
|
||||
@@ -451,7 +443,7 @@ function HomeProjectRow(
|
||||
HomeProjectsContextMenuProps & {
|
||||
project: LocalProject
|
||||
server: ServerConnection.Any
|
||||
index: () => number
|
||||
index: number
|
||||
serverSelected: boolean
|
||||
selected: boolean
|
||||
unseen: number
|
||||
@@ -464,7 +456,7 @@ function HomeProjectRow(
|
||||
return props.project.worktree
|
||||
},
|
||||
get index() {
|
||||
return props.index()
|
||||
return props.index
|
||||
},
|
||||
})
|
||||
let pointerDownSelected: boolean | undefined
|
||||
|
||||
@@ -6,16 +6,16 @@ export function HomeProjects(props: { projects: HomeProjectsController; scroll:
|
||||
return (
|
||||
<HomeProjectsView
|
||||
language={props.projects.copy.language}
|
||||
servers={props.projects.server.list}
|
||||
projects={props.projects.project.list}
|
||||
recentlyClosed={props.projects.project.recentlyClosed}
|
||||
selection={props.projects.selection.value}
|
||||
homedir={props.projects.project.homedir}
|
||||
servers={props.projects.server.list()}
|
||||
projects={props.projects.project.list()}
|
||||
recentlyClosed={props.projects.project.recentlyClosed()}
|
||||
selection={props.projects.selection.value()}
|
||||
homedir={props.projects.project.homedir()}
|
||||
serverHealth={props.projects.server.health}
|
||||
projectsForServer={props.projects.server.projects}
|
||||
collapsed={props.projects.server.collapsed}
|
||||
canDefaultServer={props.projects.server.canDefault}
|
||||
defaultServerKey={props.projects.server.defaultKey}
|
||||
canDefaultServer={props.projects.server.canDefault()}
|
||||
defaultServerKey={props.projects.server.defaultKey()}
|
||||
canRevealProject={props.projects.project.canReveal}
|
||||
unseenCount={props.projects.project.unseenCount}
|
||||
onWheel={props.scroll.viewport.containWheel}
|
||||
|
||||
@@ -295,13 +295,13 @@ function groupSessions(records: HomeSessionRecord[], language: ReturnType<typeof
|
||||
export type HomeSessionsController = ReturnType<typeof createHomeSessionsController>
|
||||
|
||||
export function HomeSessionStatusController(props: {
|
||||
server: Accessor<ServerConnection.Key>
|
||||
server: ServerConnection.Key
|
||||
record: HomeSessionRecord
|
||||
isOpenTab: (record: HomeSessionRecord) => boolean
|
||||
render: (state: { unread: Accessor<boolean>; loading: Accessor<boolean>; open: Accessor<boolean> }) => JSX.Element
|
||||
}) {
|
||||
const avatar = useSessionTabAvatarState(
|
||||
props.server,
|
||||
() => props.server,
|
||||
() => props.record.session.location.directory,
|
||||
() => props.record.session.id,
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
import { type Accessor, createMemo, For, Show, Suspense } from "solid-js"
|
||||
import { createMemo, For, Show, Suspense } from "solid-js"
|
||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
@@ -38,17 +38,17 @@ function isBackgroundOpen(event: MouseEvent) {
|
||||
|
||||
export type HomeSessionsViewProps = {
|
||||
language: ReturnType<typeof useLanguage>
|
||||
groups: Accessor<HomeSessionGroup[]>
|
||||
showProjectName: Accessor<boolean>
|
||||
server: Accessor<ServerConnection.Key>
|
||||
canCreateSession: Accessor<boolean>
|
||||
searchValue: Accessor<string>
|
||||
searchPlaceholder: Accessor<string>
|
||||
searchOpen: Accessor<boolean>
|
||||
searchLoading: Accessor<boolean>
|
||||
searchResults: Accessor<HomeSessionRecord[]>
|
||||
searchActive: Accessor<string>
|
||||
searchNoResultsLabel: Accessor<string>
|
||||
groups: HomeSessionGroup[]
|
||||
showProjectName: boolean
|
||||
server: ServerConnection.Key
|
||||
canCreateSession: boolean
|
||||
searchValue: string
|
||||
searchPlaceholder: string
|
||||
searchOpen: boolean
|
||||
searchLoading: boolean
|
||||
searchResults: HomeSessionRecord[]
|
||||
searchActive: string
|
||||
searchNoResultsLabel: string
|
||||
titleOpacity: (id: HomeSessionGroup["id"]) => number
|
||||
isOpenTab: (record: HomeSessionRecord) => boolean
|
||||
onCreateSession: () => void
|
||||
@@ -81,7 +81,7 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
|
||||
<div class="sticky top-0 z-30 shrink-0 bg-v2-background-bg-base pb-3 pt-6 lg:pt-12" onWheel={props.onWheel}>
|
||||
<HomeSessionSearch {...props} />
|
||||
<Suspense>
|
||||
<Show when={props.groups().length > 0 && props.canCreateSession()}>
|
||||
<Show when={props.groups.length > 0 && props.canCreateSession}>
|
||||
<div class="pointer-events-none absolute right-0 top-[84px] z-20 flex lg:top-[108px]">
|
||||
<ButtonV2
|
||||
data-action="home-new-session"
|
||||
@@ -113,16 +113,16 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={props.groups().length > 0}
|
||||
when={props.groups.length > 0}
|
||||
fallback={
|
||||
<HomeSessionsEmpty
|
||||
onNewSession={props.canCreateSession() ? props.onCreateSession : undefined}
|
||||
onNewSession={props.canCreateSession ? props.onCreateSession : undefined}
|
||||
language={props.language}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div ref={props.onSetContent} class="flex flex-col pt-3 pr-3 pb-16">
|
||||
<For each={props.groups()}>
|
||||
<For each={props.groups}>
|
||||
{(group, index) => (
|
||||
<>
|
||||
<HomeSessionGroupHeader
|
||||
@@ -132,7 +132,7 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
|
||||
elevated={index() === 0}
|
||||
/>
|
||||
<div
|
||||
class={`flex min-w-0 flex-col gap-px pt-4 ${index() === props.groups().length - 1 ? "" : "mb-6"}`}
|
||||
class={`flex min-w-0 flex-col gap-px pt-4 ${index() === props.groups.length - 1 ? "" : "mb-6"}`}
|
||||
>
|
||||
<For each={group.sessions}>{(record) => <HomeSessionRow {...props} record={record} />}</For>
|
||||
</div>
|
||||
@@ -205,7 +205,7 @@ function HomeSessionSearch(props: HomeSessionsViewProps) {
|
||||
return (
|
||||
<div class="w-full">
|
||||
<div ref={props.onSetSearchRoot} data-component="home-session-search" class="relative z-30 w-full">
|
||||
<Show when={props.searchOpen()}>
|
||||
<Show when={props.searchOpen}>
|
||||
<div
|
||||
data-component="home-session-search-panel"
|
||||
class={`
|
||||
@@ -217,7 +217,7 @@ function HomeSessionSearch(props: HomeSessionsViewProps) {
|
||||
<div class="flex flex-col pt-9">
|
||||
<div id={HOME_SESSION_SEARCH_RESULTS_ID} role="listbox" class="flex flex-col gap-4 pt-4">
|
||||
<Show
|
||||
when={!props.searchLoading()}
|
||||
when={!props.searchLoading}
|
||||
fallback={
|
||||
<div class="flex items-center justify-center px-4 py-3 text-v2-text-text-muted [font-weight:440]">
|
||||
<Spinner class="size-4" />
|
||||
@@ -225,7 +225,7 @@ function HomeSessionSearch(props: HomeSessionsViewProps) {
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={props.searchResults().length > 0}
|
||||
when={props.searchResults.length > 0}
|
||||
fallback={
|
||||
<p
|
||||
class={`
|
||||
@@ -233,7 +233,7 @@ function HomeSessionSearch(props: HomeSessionsViewProps) {
|
||||
text-v2-text-text-muted [font-weight:440]
|
||||
`}
|
||||
>
|
||||
{props.searchNoResultsLabel()}
|
||||
{props.searchNoResultsLabel}
|
||||
</p>
|
||||
}
|
||||
>
|
||||
@@ -248,12 +248,12 @@ function HomeSessionSearch(props: HomeSessionsViewProps) {
|
||||
</p>
|
||||
<ScrollView class="max-h-80" viewportRef={props.onSetSearchList}>
|
||||
<div class="flex flex-col gap-px pb-2">
|
||||
<For each={props.searchResults()}>
|
||||
<For each={props.searchResults}>
|
||||
{(record) => (
|
||||
<HomeSessionSearchResultRow
|
||||
{...props}
|
||||
record={record}
|
||||
selected={props.searchActive() === homeSessionSearchKey(record)}
|
||||
selected={props.searchActive === homeSessionSearchKey(record)}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
@@ -280,16 +280,14 @@ function HomeSessionSearch(props: HomeSessionsViewProps) {
|
||||
relative z-20 min-w-0 flex-1 border-0 bg-transparent outline-0
|
||||
text-v2-text-text-base [font-weight:440] placeholder:text-v2-text-text-faint
|
||||
`}
|
||||
value={props.searchValue()}
|
||||
placeholder={props.searchPlaceholder()}
|
||||
aria-label={props.searchPlaceholder()}
|
||||
aria-expanded={props.searchOpen()}
|
||||
value={props.searchValue}
|
||||
placeholder={props.searchPlaceholder}
|
||||
aria-label={props.searchPlaceholder}
|
||||
aria-expanded={props.searchOpen}
|
||||
aria-controls={HOME_SESSION_SEARCH_RESULTS_ID}
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={
|
||||
props.searchActive() && props.searchOpen()
|
||||
? `home-session-search-option-${props.searchActive()}`
|
||||
: undefined
|
||||
props.searchActive && props.searchOpen ? `home-session-search-option-${props.searchActive}` : undefined
|
||||
}
|
||||
onFocus={props.onSearchFocus}
|
||||
onInput={(event) => props.onSearchInput(event.currentTarget.value)}
|
||||
@@ -300,7 +298,7 @@ function HomeSessionSearch(props: HomeSessionsViewProps) {
|
||||
event.currentTarget.blur()
|
||||
return
|
||||
}
|
||||
if (!props.searchOpen() || props.searchResults().length === 0) return
|
||||
if (!props.searchOpen || props.searchResults.length === 0) return
|
||||
if (event.altKey || event.metaKey) return
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault()
|
||||
@@ -318,14 +316,14 @@ function HomeSessionSearch(props: HomeSessionsViewProps) {
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Show when={props.searchValue()}>
|
||||
<Show when={props.searchValue}>
|
||||
<IconButtonV2
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
class="relative z-20 shrink-0"
|
||||
icon={<IconV2 name="close" size="large" class="text-v2-icon-icon-muted" />}
|
||||
aria-label={props.searchPlaceholder()}
|
||||
aria-label={props.searchPlaceholder}
|
||||
onClick={() => {
|
||||
props.onSearchClose()
|
||||
props.onSearchFocus()
|
||||
@@ -345,7 +343,7 @@ function HomeSessionSearchResultRow(
|
||||
},
|
||||
) {
|
||||
const title = createMemo(() => sessionLabel(props.record.session))
|
||||
const showProjectName = () => props.showProjectName() && props.record.projectName
|
||||
const showProjectName = () => props.showProjectName && props.record.projectName
|
||||
const key = () => homeSessionSearchKey(props.record)
|
||||
|
||||
return (
|
||||
@@ -416,7 +414,7 @@ function HomeSessionGroupHeader(props: {
|
||||
|
||||
function HomeSessionRow(props: HomeSessionsViewProps & { record: HomeSessionRecord }) {
|
||||
const title = createMemo(() => sessionLabel(props.record.session))
|
||||
const showProjectName = () => props.showProjectName() && props.record.projectName
|
||||
const showProjectName = () => props.showProjectName && props.record.projectName
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -11,17 +11,17 @@ export function HomeSessions(props: {
|
||||
return (
|
||||
<HomeSessionsView
|
||||
language={props.sessions.copy.language}
|
||||
groups={props.sessions.data.groups}
|
||||
showProjectName={props.sessions.session.showProjectName}
|
||||
server={props.sessions.session.server}
|
||||
canCreateSession={props.sessions.session.canCreate}
|
||||
searchValue={props.search.query.value}
|
||||
searchPlaceholder={props.search.query.placeholder}
|
||||
searchOpen={props.search.query.open}
|
||||
searchLoading={props.search.result.loading}
|
||||
searchResults={props.search.result.list}
|
||||
searchActive={props.search.result.active}
|
||||
searchNoResultsLabel={props.search.result.noResultsLabel}
|
||||
groups={props.sessions.data.groups()}
|
||||
showProjectName={props.sessions.session.showProjectName()}
|
||||
server={props.sessions.session.server()}
|
||||
canCreateSession={props.sessions.session.canCreate()}
|
||||
searchValue={props.search.query.value()}
|
||||
searchPlaceholder={props.search.query.placeholder()}
|
||||
searchOpen={props.search.query.open()}
|
||||
searchLoading={props.search.result.loading()}
|
||||
searchResults={props.search.result.list()}
|
||||
searchActive={props.search.result.active()}
|
||||
searchNoResultsLabel={props.search.result.noResultsLabel()}
|
||||
titleOpacity={props.scroll.header.titleOpacity}
|
||||
isOpenTab={props.sessions.tab.isOpen}
|
||||
onCreateSession={props.sessions.session.create}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createEffect, Suspense, type ParentProps } from "solid-js"
|
||||
import { onMount, Show, Suspense, type ParentProps } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { DebugBar } from "@/components/debug-bar"
|
||||
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
|
||||
@@ -9,15 +9,17 @@ export default function Layout(props: ParentProps) {
|
||||
const platform = usePlatform()
|
||||
const [state, setState] = createStore({ debugTools: true })
|
||||
|
||||
createEffect(() => setV2Toast(true))
|
||||
onMount(() => setV2Toast(true))
|
||||
|
||||
const update: TitlebarUpdate = {
|
||||
version: () => {
|
||||
get version() {
|
||||
const state = platform.updater?.state()
|
||||
if (state?.status !== "ready") return
|
||||
if (state?.status !== "ready") return undefined
|
||||
return state.version
|
||||
},
|
||||
installing: () => platform.updater?.state().status === "installing",
|
||||
get installing() {
|
||||
return platform.updater?.state().status === "installing"
|
||||
},
|
||||
install: () => void platform.updater?.install(),
|
||||
}
|
||||
|
||||
@@ -40,7 +42,9 @@ export default function Layout(props: ParentProps) {
|
||||
<main class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict">
|
||||
<Suspense>{props.children}</Suspense>
|
||||
</main>
|
||||
{import.meta.env.DEV && state.debugTools && <DebugBar inline />}
|
||||
<Show when={import.meta.env.DEV && state.debugTools}>
|
||||
<DebugBar inline />
|
||||
</Show>
|
||||
<ToastRegion v2 />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -40,7 +40,7 @@ export default function NewSessionPage() {
|
||||
return (
|
||||
<div class="relative size-full overflow-hidden flex flex-col">
|
||||
{suspendUntilPromptReady()}
|
||||
<NewSessionStatus mount={rightMount} visible={settings.visibility.status} />
|
||||
<NewSessionStatus mount={rightMount()} visible={settings.visibility.status()} />
|
||||
<div class="flex-1 min-h-0 flex flex-col gap-2 p-2">
|
||||
<NewSessionView input={draft.input} project={project} workspace={workspace} />
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { WordmarkV2 } from "@opencode-ai/ui/v2/wordmark-v2"
|
||||
import { Show, createMemo, createSignal, type Accessor } from "solid-js"
|
||||
import { Show, createMemo, createSignal } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Portal } from "solid-js/web"
|
||||
import createPresence from "solid-presence"
|
||||
@@ -74,14 +74,14 @@ export function NewSessionView(props: {
|
||||
)
|
||||
}
|
||||
|
||||
export function NewSessionStatus(props: { mount: Accessor<HTMLElement | null>; visible: Accessor<boolean> }) {
|
||||
export function NewSessionStatus(props: { mount: HTMLElement | null; visible: boolean }) {
|
||||
const language = useLanguage()
|
||||
|
||||
return (
|
||||
<Show when={props.mount()} keyed>
|
||||
<Show when={props.mount} keyed>
|
||||
{(mount) => (
|
||||
<Portal mount={mount}>
|
||||
<Show when={props.visible()}>
|
||||
<Show when={props.visible}>
|
||||
<Tooltip placement="bottom" value={language.t("status.popover.trigger")}>
|
||||
<StatusPopoverV2 />
|
||||
</Tooltip>
|
||||
@@ -116,7 +116,7 @@ function ProviderTip() {
|
||||
})
|
||||
const openProviders = () => {
|
||||
void import("@/components/dialog-connect-provider").then(({ DialogConnectProvider }) => {
|
||||
void dialog.show(() => <DialogConnectProvider directory={() => sdk().directory} />)
|
||||
void dialog.show(() => <DialogConnectProvider directory={sdk().directory} />)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -163,7 +163,7 @@ export function TargetSessionRouteContent() {
|
||||
return (
|
||||
// Settings must keep the target-server SDK, sync, and models context and remain registered
|
||||
// when session content falls back to the route error boundary.
|
||||
<TargetServerScopedProviders directory={directory} sessionID={() => params.id}>
|
||||
<TargetServerScopedProviders directory={directory()} sessionID={params.id}>
|
||||
<TargetSessionSettingsCommand />
|
||||
<SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)} padded>
|
||||
<ResolvedTargetSessionRoute />
|
||||
@@ -183,17 +183,15 @@ export function SessionRouteErrorBoundary(
|
||||
const settings = useSettings()
|
||||
return (
|
||||
<ErrorBoundary
|
||||
fallback={(error) =>
|
||||
settings.general.newLayoutDesigns() ? (
|
||||
fallback={(error) => (
|
||||
<Show when={settings.general.newLayoutDesigns()} fallback={<ErrorPage error={error} />}>
|
||||
<SessionRouteFrame padded={props.padded}>
|
||||
<SessionPanelFrame newLayout raised={!!props.sessionID}>
|
||||
<SessionErrorFallback error={error} sessionID={props.sessionID} serverKey={props.serverKey} />
|
||||
</SessionPanelFrame>
|
||||
</SessionRouteFrame>
|
||||
) : (
|
||||
<ErrorPage error={error} />
|
||||
)
|
||||
}
|
||||
</Show>
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</ErrorBoundary>
|
||||
@@ -253,7 +251,6 @@ function ResolvedTargetSessionRoute() {
|
||||
() => sync().session.lineage,
|
||||
)
|
||||
const directory = createMemo(() => current()?.session.location.directory)
|
||||
const targetDirectory = () => directory()!
|
||||
|
||||
createEffect(() => {
|
||||
const session = current()
|
||||
@@ -270,11 +267,13 @@ function ResolvedTargetSessionRoute() {
|
||||
// the terminal. Same-workspace tab switches keep it open because warm
|
||||
// targets resolve synchronously from the sync cache.
|
||||
<Show when={directory()}>
|
||||
<SDKProvider directory={targetDirectory}>
|
||||
<DirectoryDataProvider directory={targetDirectory} server={serverKey}>
|
||||
<TargetSessionPage />
|
||||
</DirectoryDataProvider>
|
||||
</SDKProvider>
|
||||
{(dir) => (
|
||||
<SDKProvider directory={dir()}>
|
||||
<DirectoryDataProvider directory={dir()} server={serverKey()}>
|
||||
<TargetSessionPage />
|
||||
</DirectoryDataProvider>
|
||||
</SDKProvider>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -292,9 +291,7 @@ function TargetSessionPage() {
|
||||
)
|
||||
}
|
||||
|
||||
function TargetServerScopedProviders(
|
||||
props: ParentProps<{ directory?: () => string | undefined; sessionID?: () => string | undefined }>,
|
||||
) {
|
||||
function TargetServerScopedProviders(props: ParentProps<{ directory?: string; sessionID?: string }>) {
|
||||
return (
|
||||
<>
|
||||
<MarkSessionNotificationsViewed sessionID={props.sessionID} />
|
||||
@@ -303,10 +300,10 @@ function TargetServerScopedProviders(
|
||||
)
|
||||
}
|
||||
|
||||
function MarkSessionNotificationsViewed(props: { sessionID?: () => string | undefined }) {
|
||||
function MarkSessionNotificationsViewed(props: { sessionID?: string }) {
|
||||
const notification = useNotification()
|
||||
createEffect(() => {
|
||||
const sessionID = props.sessionID?.()
|
||||
const sessionID = props.sessionID
|
||||
if (!notification.ready() || !sessionID) return
|
||||
if (notification.session.unseenCount(sessionID) === 0) return
|
||||
notification.session.markViewed(sessionID)
|
||||
@@ -1238,8 +1235,8 @@ export default function Page() {
|
||||
<SessionReviewTab
|
||||
title={changesTitle()}
|
||||
empty={reviewEmpty(input)}
|
||||
diffs={reviewDiffs}
|
||||
view={controller.layout.view}
|
||||
diffs={reviewDiffs()}
|
||||
view={controller.layout.view()}
|
||||
diffStyle={input.diffStyle}
|
||||
onDiffStyleChange={input.onDiffStyleChange}
|
||||
onScrollRef={(el) => setTree("reviewScroll", el)}
|
||||
@@ -1272,8 +1269,12 @@ export default function Page() {
|
||||
get empty() {
|
||||
return reviewEmptyV2()
|
||||
},
|
||||
diffs: reviewDiffs,
|
||||
diffsReady: reviewReady,
|
||||
get diffs() {
|
||||
return reviewDiffs()
|
||||
},
|
||||
get diffsReady() {
|
||||
return reviewReady()
|
||||
},
|
||||
get diffVersion() {
|
||||
return vcsQuery.dataUpdatedAt
|
||||
},
|
||||
@@ -2072,11 +2073,11 @@ export default function Page() {
|
||||
onScheduleScrollState={scheduleScrollState}
|
||||
onAutoScrollHandleScroll={autoScroll.handleScroll}
|
||||
onMarkScrollGesture={markScrollGesture}
|
||||
hasScrollGesture={hasScrollGesture}
|
||||
hasScrollGesture={hasScrollGesture()}
|
||||
onUserScroll={markUserScroll}
|
||||
onHistoryScroll={onHistoryScroll}
|
||||
onAutoScrollInteraction={autoScroll.handleInteraction}
|
||||
shouldAnchorBottom={() =>
|
||||
shouldAnchorBottom={
|
||||
!location.hash && !store.messageId && !ui.pendingMessage && !autoScroll.userScrolled()
|
||||
}
|
||||
centered={centered()}
|
||||
@@ -2251,7 +2252,14 @@ export default function Page() {
|
||||
width: sessionPanelWidth(),
|
||||
}}
|
||||
>
|
||||
{settings.general.newLayoutDesigns() ? (
|
||||
<Show
|
||||
when={settings.general.newLayoutDesigns()}
|
||||
fallback={
|
||||
<SessionPanelFrame newLayout={false} raised={!!controller.identity.params.id}>
|
||||
{sessionPanelContent()}
|
||||
</SessionPanelFrame>
|
||||
}
|
||||
>
|
||||
<Show when={sessionPanelKey()} keyed>
|
||||
{(_) => (
|
||||
<SessionPanelFrame newLayout raised={!!controller.identity.params.id}>
|
||||
@@ -2259,11 +2267,7 @@ export default function Page() {
|
||||
</SessionPanelFrame>
|
||||
)}
|
||||
</Show>
|
||||
) : (
|
||||
<SessionPanelFrame newLayout={false} raised={!!controller.identity.params.id}>
|
||||
{sessionPanelContent()}
|
||||
</SessionPanelFrame>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<Show when={desktopSessionResizeOpen()}>
|
||||
<div onPointerDown={() => size.start()}>
|
||||
@@ -2287,13 +2291,13 @@ export default function Page() {
|
||||
<Show when={!newSessionDesign() && desktopSidePanelOpen()}>
|
||||
<Suspense>
|
||||
<SessionSidePanel
|
||||
canReview={canReview}
|
||||
diffs={reviewDiffs}
|
||||
diffsReady={reviewReady}
|
||||
empty={reviewEmptyText}
|
||||
hasReview={hasReview}
|
||||
reviewHasFocusableContent={hasReview}
|
||||
reviewCount={reviewCount}
|
||||
canReview={canReview()}
|
||||
diffs={reviewDiffs()}
|
||||
diffsReady={reviewReady()}
|
||||
empty={reviewEmptyText()}
|
||||
hasReview={hasReview()}
|
||||
reviewHasFocusableContent={hasReview()}
|
||||
reviewCount={reviewCount()}
|
||||
reviewPanel={reviewPanel}
|
||||
activeDiff={activeReviewFile()}
|
||||
focusReviewDiff={focusReviewDiff}
|
||||
@@ -2309,13 +2313,13 @@ export default function Page() {
|
||||
<div class="min-h-0 flex-1">
|
||||
<Suspense>
|
||||
<SessionSidePanel
|
||||
canReview={canReview}
|
||||
diffs={reviewDiffs}
|
||||
diffsReady={reviewReady}
|
||||
empty={reviewEmptyText}
|
||||
hasReview={hasReview}
|
||||
reviewHasFocusableContent={() => hasReview() || reviewV2State.sidebarOpened()}
|
||||
reviewCount={reviewCount}
|
||||
canReview={canReview()}
|
||||
diffs={reviewDiffs()}
|
||||
diffsReady={reviewReady()}
|
||||
empty={reviewEmptyText()}
|
||||
hasReview={hasReview()}
|
||||
reviewHasFocusableContent={hasReview() || reviewV2State.sidebarOpened()}
|
||||
reviewCount={reviewCount()}
|
||||
reviewPanel={reviewPanelV2}
|
||||
reviewSidebarToggle={(disabled) => (
|
||||
<SessionReviewV2SidebarToggle
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useSpring } from "@opencode-ai/ui/motion-spring"
|
||||
import { TextReveal } from "@opencode-ai/ui/text-reveal"
|
||||
import { TextStrikethrough } from "@opencode-ai/ui/text-strikethrough"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { Index, createEffect, createMemo } from "solid-js"
|
||||
import { Index, Match, Switch, createEffect, createMemo } from "solid-js"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/context/language"
|
||||
@@ -142,15 +142,16 @@ export function SessionTodoDock(props: {
|
||||
}}
|
||||
>
|
||||
<Index each={progress()}>
|
||||
{(item) =>
|
||||
item() === doneToken ? (
|
||||
<AnimatedNumber value={done()} />
|
||||
) : item() === totalToken ? (
|
||||
<AnimatedNumber value={total()} />
|
||||
) : (
|
||||
<span>{item()}</span>
|
||||
)
|
||||
}
|
||||
{(item) => (
|
||||
<Switch fallback={<span>{item()}</span>}>
|
||||
<Match when={item() === doneToken}>
|
||||
<AnimatedNumber value={done()} />
|
||||
</Match>
|
||||
<Match when={item() === totalToken}>
|
||||
<AnimatedNumber value={total()} />
|
||||
</Match>
|
||||
</Switch>
|
||||
)}
|
||||
</Index>
|
||||
</span>
|
||||
<div
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @ts-nocheck
|
||||
import { createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { createEffect, createMemo, For, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { Todo } from "@/types"
|
||||
import { useServerSync } from "@/context/global-sync"
|
||||
@@ -326,11 +326,13 @@ export const Playground = {
|
||||
<button onClick={cycle} style={btn(step() > 0)}>
|
||||
Cycle progress ({step()}/3 done)
|
||||
</button>
|
||||
{[0, 1, 2, 3].map((value) => (
|
||||
<button onClick={() => setCfg("step", value)} style={btn(step() === value)}>
|
||||
{value} done
|
||||
</button>
|
||||
))}
|
||||
<For each={[0, 1, 2, 3]}>
|
||||
{(value) => (
|
||||
<button onClick={() => setCfg("step", value)} style={btn(step() === value)}>
|
||||
{value} done
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "grid", gap: "10px", "max-width": "560px" }}>
|
||||
|
||||
@@ -19,8 +19,8 @@ type ReviewDiff = FileDiffInfo
|
||||
export interface SessionReviewTabProps {
|
||||
title?: JSX.Element
|
||||
empty?: JSX.Element
|
||||
diffs: () => ReviewDiff[]
|
||||
view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
|
||||
diffs: ReviewDiff[]
|
||||
view: ReturnType<ReturnType<typeof useLayout>["view"]>
|
||||
diffStyle: DiffStyle
|
||||
onDiffStyleChange?: (style: DiffStyle) => void
|
||||
onViewFile?: (file: string) => void
|
||||
@@ -77,7 +77,7 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
|
||||
if (!el || !layout.ready() || userInteracted) return
|
||||
if (el.clientHeight === 0 || el.clientWidth === 0) return
|
||||
|
||||
const s = props.view().scroll("review")
|
||||
const s = props.view.scroll("review")
|
||||
if (!s || (s.x === 0 && s.y === 0)) return
|
||||
|
||||
const maxY = Math.max(0, el.scrollHeight - el.clientHeight)
|
||||
@@ -111,14 +111,14 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
|
||||
if (!layout.ready()) return
|
||||
if (el.clientHeight === 0 || el.clientWidth === 0) return
|
||||
|
||||
props.view().setScroll("review", {
|
||||
props.view.setScroll("review", {
|
||||
x: el.scrollLeft,
|
||||
y: el.scrollTop,
|
||||
})
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
props.diffs().length
|
||||
props.diffs.length
|
||||
props.diffStyle
|
||||
if (!layout.ready()) return
|
||||
queueRestore()
|
||||
@@ -145,14 +145,14 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
|
||||
}}
|
||||
onScroll={handleScroll}
|
||||
onDiffRendered={queueRestore}
|
||||
open={props.view().review.open()}
|
||||
onOpenChange={props.view().review.setOpen}
|
||||
open={props.view.review.open()}
|
||||
onOpenChange={props.view.review.setOpen}
|
||||
classes={{
|
||||
root: props.classes?.root ?? "pr-3",
|
||||
header: props.classes?.header ?? "px-3",
|
||||
container: props.classes?.container ?? "pl-3",
|
||||
}}
|
||||
diffs={props.diffs()}
|
||||
diffs={props.diffs}
|
||||
diffStyle={props.diffStyle}
|
||||
onDiffStyleChange={props.onDiffStyleChange}
|
||||
onViewFile={props.onViewFile}
|
||||
|
||||
@@ -65,13 +65,13 @@ function renderDiff(value: ReviewDiff): value is RenderDiff {
|
||||
}
|
||||
|
||||
export function SessionSidePanel(props: {
|
||||
canReview: () => boolean
|
||||
diffs: () => ReviewDiff[]
|
||||
diffsReady: () => boolean
|
||||
empty: () => string
|
||||
hasReview: () => boolean
|
||||
reviewHasFocusableContent: () => boolean
|
||||
reviewCount: () => number
|
||||
canReview: boolean
|
||||
diffs: ReviewDiff[]
|
||||
diffsReady: boolean
|
||||
empty: string
|
||||
hasReview: boolean
|
||||
reviewHasFocusableContent: boolean
|
||||
reviewCount: number
|
||||
reviewPanel: () => JSX.Element
|
||||
reviewSidebarToggle?: (disabled: boolean) => JSX.Element
|
||||
fileBrowserState?: SessionFileBrowserState
|
||||
@@ -113,7 +113,7 @@ export function SessionSidePanel(props: {
|
||||
})
|
||||
const treeWidth = createMemo(() => (fileOpen() ? `${fileTreeWidth()}px` : "0px"))
|
||||
|
||||
const diffs = createMemo(() => props.diffs().filter(renderDiff))
|
||||
const diffs = createMemo(() => props.diffs.filter(renderDiff))
|
||||
const diffFiles = createMemo(() => diffs().map((d) => d.file))
|
||||
const kinds = createMemo(() => {
|
||||
const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => {
|
||||
@@ -177,7 +177,7 @@ export function SessionSidePanel(props: {
|
||||
pathFromTab: file.pathFromTab,
|
||||
normalizeTab,
|
||||
review: reviewTab,
|
||||
hasReview: props.canReview,
|
||||
hasReview: () => props.canReview,
|
||||
fileBrowser: () => !!props.fileBrowserState,
|
||||
})
|
||||
const contextOpen = tabState.contextOpen
|
||||
@@ -348,7 +348,7 @@ export function SessionSidePanel(props: {
|
||||
onCleanup(stop)
|
||||
}}
|
||||
>
|
||||
<Show when={reviewTab() && props.canReview()}>
|
||||
<Show when={reviewTab() && props.canReview}>
|
||||
<Tabs.Trigger
|
||||
value="review"
|
||||
id={reviewTabID}
|
||||
@@ -356,8 +356,8 @@ export function SessionSidePanel(props: {
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div>{language.t("session.tab.review")}</div>
|
||||
<Show when={props.hasReview()}>
|
||||
<div>{props.reviewCount()}</div>
|
||||
<Show when={props.hasReview}>
|
||||
<div>{props.reviewCount}</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Tabs.Trigger>
|
||||
@@ -463,12 +463,12 @@ export function SessionSidePanel(props: {
|
||||
</Tabs.List>
|
||||
</div>
|
||||
|
||||
<Show when={reviewTab() && props.canReview() && activeTab() === "review"}>
|
||||
<Show when={reviewTab() && props.canReview && activeTab() === "review"}>
|
||||
<div
|
||||
id={reviewTabPanelID}
|
||||
role="tabpanel"
|
||||
aria-labelledby={reviewTabID}
|
||||
tabIndex={props.reviewHasFocusableContent() ? undefined : 0}
|
||||
tabIndex={props.reviewHasFocusableContent ? undefined : 0}
|
||||
data-slot="tabs-content"
|
||||
class="flex flex-col h-full overflow-hidden contain-strict"
|
||||
>
|
||||
@@ -559,14 +559,14 @@ export function SessionSidePanel(props: {
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={reviewTab() && props.canReview()}>
|
||||
<Show when={reviewTab() && props.canReview}>
|
||||
<Tabs.Trigger
|
||||
value="review"
|
||||
id={reviewTabID}
|
||||
aria-controls={activeTab() === "review" ? reviewTabPanelID : undefined}
|
||||
>
|
||||
{props.hasReview()
|
||||
? language.t("session.review.filesChanged", { count: props.reviewCount() })
|
||||
{props.hasReview
|
||||
? language.t("session.review.filesChanged", { count: props.reviewCount })
|
||||
: language.t("session.tab.review")}
|
||||
</Tabs.Trigger>
|
||||
</Show>
|
||||
@@ -611,7 +611,7 @@ export function SessionSidePanel(props: {
|
||||
fallback={
|
||||
<SortableTabV2
|
||||
tab={tab}
|
||||
index={() => tabs().all().indexOf(tab)}
|
||||
index={tabs().all().indexOf(tab)}
|
||||
temporary={temporaryTab() === tab}
|
||||
onTabClose={tabs().close}
|
||||
onTabDoubleClick={temporaryTab() === tab ? openTab : undefined}
|
||||
@@ -691,12 +691,12 @@ export function SessionSidePanel(props: {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={reviewTab() && props.canReview() && activeTab() === "review"}>
|
||||
<Show when={reviewTab() && props.canReview && activeTab() === "review"}>
|
||||
<div
|
||||
id={reviewTabPanelID}
|
||||
role="tabpanel"
|
||||
aria-labelledby={reviewTabID}
|
||||
tabIndex={props.reviewHasFocusableContent() ? undefined : 0}
|
||||
tabIndex={props.reviewHasFocusableContent ? undefined : 0}
|
||||
data-slot="tabs-content"
|
||||
class="flex flex-col h-full overflow-hidden contain-strict"
|
||||
>
|
||||
@@ -782,14 +782,14 @@ export function SessionSidePanel(props: {
|
||||
when={settings.general.newLayoutDesigns()}
|
||||
fallback={
|
||||
<>
|
||||
{props.reviewCount()}{" "}
|
||||
{props.reviewCount}{" "}
|
||||
{language.t(
|
||||
props.reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other",
|
||||
props.reviewCount === 1 ? "session.review.change.one" : "session.review.change.other",
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{language.t("session.review.filesChanged", { count: props.reviewCount() })}
|
||||
{language.t("session.review.filesChanged", { count: props.reviewCount })}
|
||||
</Show>
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}>
|
||||
@@ -799,9 +799,9 @@ export function SessionSidePanel(props: {
|
||||
<Show when={fileTreeTab() === "changes"}>
|
||||
<Tabs.Content value="changes" class="bg-background-stronger px-3 py-0">
|
||||
<Switch>
|
||||
<Match when={props.hasReview() || !props.diffsReady()}>
|
||||
<Match when={props.hasReview || !props.diffsReady}>
|
||||
<Show
|
||||
when={props.diffsReady()}
|
||||
when={props.diffsReady}
|
||||
fallback={
|
||||
<div class="px-2 py-2 text-12-regular text-text-weak">
|
||||
{language.t("common.loading")}
|
||||
|
||||
@@ -273,7 +273,7 @@ export function TerminalPanelV2(props: { stacked?: boolean } = {}) {
|
||||
>
|
||||
<For each={all()}>
|
||||
{(pty, index) => (
|
||||
<SortableTerminalTabV2 terminal={pty} index={index} newLayout={newLayout()} onClose={close} />
|
||||
<SortableTerminalTabV2 terminal={pty} index={index()} newLayout={newLayout()} onClose={close} />
|
||||
)}
|
||||
</For>
|
||||
<div class="h-full flex items-center justify-center">
|
||||
|
||||
@@ -210,11 +210,11 @@ type MessageTimelineProps = {
|
||||
onScheduleScrollState: (el: HTMLDivElement) => void
|
||||
onAutoScrollHandleScroll: () => void
|
||||
onMarkScrollGesture: (target?: EventTarget | null) => void
|
||||
hasScrollGesture: () => boolean
|
||||
hasScrollGesture: boolean
|
||||
onUserScroll: () => void
|
||||
onHistoryScroll: () => void
|
||||
onAutoScrollInteraction: (event: MouseEvent) => void
|
||||
shouldAnchorBottom: () => boolean
|
||||
shouldAnchorBottom: boolean
|
||||
centered: boolean
|
||||
setContentRef: (el: HTMLDivElement) => void
|
||||
userMessages: UserMessage[]
|
||||
@@ -243,7 +243,7 @@ function MessageTimelineView(
|
||||
const ownerSessionKey = props.data.sessionKey()
|
||||
const cached = timelineCache.get(ownerSessionKey)
|
||||
const initialMeasurements = cached?.measurements
|
||||
const coldBottomMount = !initialMeasurements?.length && props.shouldAnchorBottom()
|
||||
const coldBottomMount = !initialMeasurements?.length && props.shouldAnchorBottom
|
||||
|
||||
const [listRoot, setListRoot] = createSignal<HTMLDivElement>()
|
||||
const sessionID = props.data.sessionID
|
||||
@@ -338,7 +338,7 @@ function MessageTimelineView(
|
||||
},
|
||||
getScrollElement: () => listRoot() ?? null,
|
||||
observeElementOffset: observeElementOffsetReconnectAware,
|
||||
initialOffset: () => (props.shouldAnchorBottom() ? Number.MAX_SAFE_INTEGER : 0),
|
||||
initialOffset: () => (props.shouldAnchorBottom ? Number.MAX_SAFE_INTEGER : 0),
|
||||
initialMeasurementsCache: initialMeasurements,
|
||||
estimateSize: () => timelineFallbackItemSize,
|
||||
scrollToFn: (offset, options, instance) => {
|
||||
@@ -376,11 +376,11 @@ function MessageTimelineView(
|
||||
const resizeItem = virtualizer.resizeItem
|
||||
let resizeAnchorScheduled = false
|
||||
const anchorResizedBottom = () => {
|
||||
if (resizeAnchorScheduled || props.hasScrollGesture()) return
|
||||
if (resizeAnchorScheduled || props.hasScrollGesture) return
|
||||
resizeAnchorScheduled = true
|
||||
queueMicrotask(() => {
|
||||
resizeAnchorScheduled = false
|
||||
if (!props.shouldAnchorBottom() || props.hasScrollGesture()) return
|
||||
if (!props.shouldAnchorBottom || props.hasScrollGesture) return
|
||||
virtualizer.scrollToEnd()
|
||||
})
|
||||
}
|
||||
@@ -405,10 +405,10 @@ function MessageTimelineView(
|
||||
})
|
||||
}
|
||||
resizeItem(index, size)
|
||||
if (root && props.shouldAnchorBottom()) anchorResizedBottom()
|
||||
if (root && props.shouldAnchorBottom) anchorResizedBottom()
|
||||
}
|
||||
virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item) => {
|
||||
if (props.shouldAnchorBottom()) return false
|
||||
if (props.shouldAnchorBottom) return false
|
||||
const first = virtualizer.range?.startIndex
|
||||
return first !== undefined && item.index < first
|
||||
}
|
||||
@@ -429,18 +429,18 @@ function MessageTimelineView(
|
||||
let overscanFrame: number | undefined
|
||||
onMount(() => {
|
||||
overscanFrame = requestAnimationFrame(() => {
|
||||
if (props.shouldAnchorBottom()) virtualizer.scrollToEnd()
|
||||
if (props.shouldAnchorBottom) virtualizer.scrollToEnd()
|
||||
overscanFrame = requestAnimationFrame(() => {
|
||||
overscanFrame = undefined
|
||||
if (renderOverscan() < 20) setRenderOverscan(20)
|
||||
if (props.shouldAnchorBottom()) virtualizer.scrollToEnd()
|
||||
if (props.shouldAnchorBottom) virtualizer.scrollToEnd()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const maybeAnchorBottom = () => {
|
||||
if (timelineRows().length === 0) return
|
||||
if (!props.shouldAnchorBottom() || props.hasScrollGesture()) return
|
||||
if (!props.shouldAnchorBottom || props.hasScrollGesture) return
|
||||
if (resizePinFrame !== undefined) cancelAnimationFrame(resizePinFrame)
|
||||
clearPrependAnchor()
|
||||
if (prependAnchorFrame !== undefined) cancelAnimationFrame(prependAnchorFrame)
|
||||
@@ -552,7 +552,7 @@ function MessageTimelineView(
|
||||
if (prependLoading) updatePrependAnchor()
|
||||
props.onScheduleScrollState(event.currentTarget)
|
||||
props.onHistoryScroll()
|
||||
if (!props.hasScrollGesture()) return
|
||||
if (!props.hasScrollGesture) return
|
||||
props.onUserScroll()
|
||||
props.onAutoScrollHandleScroll()
|
||||
props.onMarkScrollGesture(event.currentTarget)
|
||||
@@ -709,21 +709,21 @@ function MessageTimelineView(
|
||||
)
|
||||
}
|
||||
|
||||
function TimelineRowFrame(input: { row: Accessor<FramedTimelineRow>; children: JSX.Element }) {
|
||||
function TimelineRowFrame(input: { row: FramedTimelineRow; children: JSX.Element }) {
|
||||
const anchor = () => {
|
||||
const row = input.row()
|
||||
const row = input.row
|
||||
return row._tag === "CommentStrip" || (row._tag === "UserMessage" && row.anchor)
|
||||
}
|
||||
const previousAssistantPart = () => {
|
||||
const row = input.row()
|
||||
const row = input.row
|
||||
return row._tag === "AssistantPart" && row.previousAssistantPart
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
id={anchor() ? props.anchor(input.row().userMessageID) : undefined}
|
||||
data-message-id={input.row().userMessageID}
|
||||
data-timeline-row={input.row()._tag}
|
||||
id={anchor() ? props.anchor(input.row.userMessageID) : undefined}
|
||||
data-message-id={input.row.userMessageID}
|
||||
data-timeline-row={input.row._tag}
|
||||
classList={{
|
||||
"min-w-0 w-full max-w-full": true,
|
||||
"md:max-w-200 2xl:max-w-[1000px]": props.centered,
|
||||
@@ -748,7 +748,7 @@ function MessageTimelineView(
|
||||
getMsgParts(commentStripRow().userMessageID).flatMap((part) => MessageComment.fromPart(part) ?? []),
|
||||
)
|
||||
return (
|
||||
<TimelineRowFrame row={commentStripRow}>
|
||||
<TimelineRowFrame row={commentStripRow()}>
|
||||
<div class="w-full px-4 md:px-5 pb-2">
|
||||
<div class="ms-auto max-w-[82%] overflow-x-auto no-scrollbar">
|
||||
<div class="flex w-max min-w-full justify-end gap-2">
|
||||
@@ -797,7 +797,7 @@ function MessageTimelineView(
|
||||
return getMsgParts(userMessageRow().userMessageID).flatMap((part) => MessageComment.fromPart(part) ?? [])
|
||||
})
|
||||
return (
|
||||
<TimelineRowFrame row={userMessageRow}>
|
||||
<TimelineRowFrame row={userMessageRow()}>
|
||||
<Show when={message()}>
|
||||
{(message) => (
|
||||
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
||||
@@ -819,7 +819,7 @@ function MessageTimelineView(
|
||||
case "TurnDivider": {
|
||||
const turnDividerRow = row as Accessor<TimelineRowByTag<"TurnDivider">>
|
||||
return (
|
||||
<TimelineRowFrame row={turnDividerRow}>
|
||||
<TimelineRowFrame row={turnDividerRow()}>
|
||||
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
||||
<div data-slot="session-turn-compaction">
|
||||
<MessageDivider
|
||||
@@ -835,7 +835,7 @@ function MessageTimelineView(
|
||||
case "AssistantPart": {
|
||||
const assistantPartRow = row as Accessor<TimelineRowByTag<"AssistantPart">>
|
||||
return (
|
||||
<TimelineRowFrame row={assistantPartRow}>
|
||||
<TimelineRowFrame row={assistantPartRow()}>
|
||||
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
||||
<div
|
||||
data-slot="session-turn-assistant-content"
|
||||
@@ -850,7 +850,7 @@ function MessageTimelineView(
|
||||
case "Thinking": {
|
||||
const thinkingRow = row as Accessor<TimelineRowByTag<"Thinking">>
|
||||
return (
|
||||
<TimelineRowFrame row={thinkingRow}>
|
||||
<TimelineRowFrame row={thinkingRow()}>
|
||||
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
||||
<TimelineThinkingRow
|
||||
reasoningHeading={thinkingRow().reasoningHeading}
|
||||
@@ -863,7 +863,7 @@ function MessageTimelineView(
|
||||
case "Retry": {
|
||||
const retryRow = row as Accessor<TimelineRowByTag<"Retry">>
|
||||
return (
|
||||
<TimelineRowFrame row={retryRow}>
|
||||
<TimelineRowFrame row={retryRow()}>
|
||||
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
||||
<SessionRetry status={sessionStatus()} show={activeMessageID() === retryRow().userMessageID} />
|
||||
</div>
|
||||
@@ -873,7 +873,7 @@ function MessageTimelineView(
|
||||
case "DiffSummary": {
|
||||
const diffSummaryRow = row as Accessor<TimelineRowByTag<"DiffSummary">>
|
||||
return (
|
||||
<TimelineRowFrame row={diffSummaryRow}>
|
||||
<TimelineRowFrame row={diffSummaryRow()}>
|
||||
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
||||
<TimelineDiffSummaryRow diffs={diffSummaryRow().diffs} />
|
||||
</div>
|
||||
@@ -883,7 +883,7 @@ function MessageTimelineView(
|
||||
case "Error": {
|
||||
const errorRow = row as Accessor<TimelineRowByTag<"Error">>
|
||||
return (
|
||||
<TimelineRowFrame row={errorRow}>
|
||||
<TimelineRowFrame row={errorRow()}>
|
||||
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
||||
<Card variant="error" class="error-card">
|
||||
{errorRow().text}
|
||||
|
||||
@@ -35,8 +35,8 @@ type ReviewDiff = FileDiffInfo
|
||||
export type ReviewPanelV2Props = {
|
||||
title?: JSX.Element
|
||||
empty?: JSX.Element
|
||||
diffs: () => ReviewDiff[]
|
||||
diffsReady: () => boolean
|
||||
diffs: ReviewDiff[]
|
||||
diffsReady: boolean
|
||||
diffVersion?: number
|
||||
loadDiff?: (path: string, version?: number) => Promise<RenderDiff | undefined>
|
||||
activeFile?: string
|
||||
@@ -56,7 +56,7 @@ export type ReviewPanelV2Props = {
|
||||
export function ReviewPanelV2(props: ReviewPanelV2Props) {
|
||||
const sdk = useSDK()
|
||||
|
||||
const diffs = createMemo(() => props.diffs().filter(filterRenderableDiff))
|
||||
const diffs = createMemo(() => props.diffs.filter(filterRenderableDiff))
|
||||
const filteredFiles = createMemo(() =>
|
||||
filterReviewFiles(
|
||||
diffs().map((diff) => diff.file),
|
||||
@@ -122,11 +122,11 @@ export function ReviewPanelV2(props: ReviewPanelV2Props) {
|
||||
state={props.state}
|
||||
diffsReady={props.diffsReady}
|
||||
onSelectFile={props.onSelectFile}
|
||||
diffs={diffs}
|
||||
filteredFiles={filteredFiles}
|
||||
searching={searching}
|
||||
kinds={treeKinds}
|
||||
activeDiff={activeDiff}
|
||||
diffs={diffs()}
|
||||
filteredFiles={filteredFiles()}
|
||||
searching={searching()}
|
||||
kinds={treeKinds()}
|
||||
activeDiff={activeDiff()}
|
||||
/>
|
||||
}
|
||||
activeFile={activeDiff()}
|
||||
@@ -170,19 +170,19 @@ export function ReviewPanelV2(props: ReviewPanelV2Props) {
|
||||
function ReviewPanelV2Sidebar(props: {
|
||||
title?: JSX.Element
|
||||
state: ReviewPanelV2State
|
||||
diffsReady: () => boolean
|
||||
diffsReady: boolean
|
||||
onSelectFile: (path: string) => void
|
||||
diffs: () => RenderDiff[]
|
||||
filteredFiles: () => string[]
|
||||
searching: () => boolean
|
||||
kinds: () => ReturnType<typeof reviewDiffKinds>
|
||||
activeDiff: () => string | undefined
|
||||
diffs: RenderDiff[]
|
||||
filteredFiles: string[]
|
||||
searching: boolean
|
||||
kinds: ReturnType<typeof reviewDiffKinds>
|
||||
activeDiff: string | undefined
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const [explicitHighlight, setExplicitHighlight] = createSignal<string | undefined>()
|
||||
const highlightedPath = createMemo(() => {
|
||||
if (!props.searching()) return undefined
|
||||
const files = props.filteredFiles()
|
||||
if (!props.searching) return undefined
|
||||
const files = props.filteredFiles
|
||||
if (files.length === 0) return undefined
|
||||
const explicit = explicitHighlight()
|
||||
if (explicit && files.includes(explicit)) return explicit
|
||||
@@ -190,8 +190,8 @@ function ReviewPanelV2Sidebar(props: {
|
||||
})
|
||||
|
||||
const onFilterKeyDown = (event: KeyboardEvent & { currentTarget: HTMLInputElement }) => {
|
||||
if (!props.searching()) return
|
||||
applyFileListKeyDown(event, props.filteredFiles(), highlightedPath(), {
|
||||
if (!props.searching) return
|
||||
applyFileListKeyDown(event, props.filteredFiles, highlightedPath(), {
|
||||
onHighlight: setExplicitHighlight,
|
||||
onSelect: props.onSelectFile,
|
||||
})
|
||||
@@ -202,7 +202,7 @@ function ReviewPanelV2Sidebar(props: {
|
||||
open={props.state.sidebarOpened()}
|
||||
transition={props.state.sidebarTransition()}
|
||||
title={props.title}
|
||||
stats={<DiffChanges changes={props.diffs()} />}
|
||||
stats={<DiffChanges changes={props.diffs} />}
|
||||
filter={props.state.filter()}
|
||||
onFilterChange={props.state.setFilter}
|
||||
onFilterKeyDown={onFilterKeyDown}
|
||||
@@ -212,7 +212,7 @@ function ReviewPanelV2Sidebar(props: {
|
||||
maxWidth={SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX}
|
||||
>
|
||||
<Show
|
||||
when={props.diffsReady()}
|
||||
when={props.diffsReady}
|
||||
fallback={
|
||||
<div class="px-2 py-2 text-12-regular text-text-weak">
|
||||
{language.t("common.loading")}
|
||||
@@ -221,25 +221,25 @@ function ReviewPanelV2Sidebar(props: {
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={props.searching()}
|
||||
when={props.searching}
|
||||
fallback={
|
||||
<FileTreeV2
|
||||
allowed={props.filteredFiles()}
|
||||
kinds={props.kinds()}
|
||||
allowed={props.filteredFiles}
|
||||
kinds={props.kinds}
|
||||
draggable={false}
|
||||
active={props.activeDiff()}
|
||||
active={props.activeDiff}
|
||||
onFileClick={(node) => props.onSelectFile(node.path)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={props.filteredFiles().length > 0}
|
||||
when={props.filteredFiles.length > 0}
|
||||
fallback={<div class="px-2 py-2 text-12-regular text-text-weak">{language.t("palette.empty")}</div>}
|
||||
>
|
||||
<SessionFileListV2
|
||||
files={props.filteredFiles()}
|
||||
kinds={props.kinds()}
|
||||
active={props.activeDiff()}
|
||||
files={props.filteredFiles}
|
||||
kinds={props.kinds}
|
||||
active={props.activeDiff}
|
||||
highlighted={highlightedPath()}
|
||||
onFileClick={(path) => {
|
||||
setExplicitHighlight(path)
|
||||
|
||||
@@ -22,6 +22,7 @@ await rm(outdir, { recursive: true, force: true })
|
||||
|
||||
const singleFlag = process.argv.includes("--single")
|
||||
const baselineFlag = process.argv.includes("--baseline")
|
||||
const requestedTarget = process.argv.find((arg) => arg.startsWith("--target="))?.slice("--target=".length)
|
||||
const skipInstall = process.argv.includes("--skip-install")
|
||||
const skipWebUi = process.argv.includes("--skip-web-ui")
|
||||
const solidPlugin = createSolidTransformPlugin()
|
||||
@@ -46,13 +47,17 @@ const allTargets: {
|
||||
{ os: "win32", arch: "x64", avx2: false },
|
||||
]
|
||||
|
||||
const targets = singleFlag
|
||||
? allTargets.filter((item) => {
|
||||
if (item.os !== process.platform || item.arch !== process.arch) return false
|
||||
if (item.avx2 === false) return baselineFlag
|
||||
return item.abi === undefined
|
||||
})
|
||||
: allTargets
|
||||
const targets =
|
||||
requestedTarget !== undefined
|
||||
? allTargets.filter((item) => targetName(item) === requestedTarget)
|
||||
: singleFlag
|
||||
? allTargets.filter((item) => {
|
||||
if (item.os !== process.platform || item.arch !== process.arch) return false
|
||||
if (item.avx2 === false) return baselineFlag
|
||||
return item.abi === undefined
|
||||
})
|
||||
: allTargets
|
||||
if (!targets.length) throw new Error(`Unknown build target: ${requestedTarget}`)
|
||||
|
||||
if (!skipInstall) await $`bun install --os="*" --cpu="*" @opentui/core@${pkg.dependencies["@opentui/core"]}`
|
||||
const appArchive = await buildAppArchive(Script.channel, { skipBuild: skipWebUi })
|
||||
@@ -81,15 +86,7 @@ for (const item of targets) {
|
||||
}))
|
||||
},
|
||||
}
|
||||
const target = [
|
||||
binary,
|
||||
item.os === "win32" ? "windows" : item.os,
|
||||
item.arch,
|
||||
item.avx2 === false ? "baseline" : undefined,
|
||||
item.abi,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("-")
|
||||
const target = targetName(item)
|
||||
const name = target.replace(binary, "cli")
|
||||
console.log(`building ${name}`)
|
||||
const result = await Bun.build({
|
||||
@@ -143,3 +140,15 @@ for (const item of targets) {
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function targetName(item: (typeof allTargets)[number]) {
|
||||
return [
|
||||
binary,
|
||||
item.os === "win32" ? "windows" : item.os,
|
||||
item.arch,
|
||||
item.avx2 === false ? "baseline" : undefined,
|
||||
item.abi,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("-")
|
||||
}
|
||||
|
||||
@@ -447,7 +447,7 @@ function UpdateFooter(props: {
|
||||
})
|
||||
|
||||
return (
|
||||
<box width="100%" height={4} flexDirection="row" gap={1} live={props.animating()}>
|
||||
<box width="100%" height={4} flexDirection="row" gap={1} paddingLeft={1} live={props.animating()}>
|
||||
<Monogram ink={monogramInk} />
|
||||
<box flexDirection="column" flexGrow={1} overflow="hidden">
|
||||
<CellLine cells={header()} />
|
||||
|
||||
@@ -1362,11 +1362,11 @@ export type Endpoint16_1Input = {
|
||||
readonly limit?: number | undefined
|
||||
}
|
||||
export type Endpoint16_1Output = { readonly location: Location.Info; readonly data: ReadonlyArray<FileSystem.Entry> }
|
||||
export type FileGetOperation<E = never> = (input: Endpoint16_1Input) => Effect.Effect<Endpoint16_1Output, E>
|
||||
export type FileFindOperation<E = never> = (input: Endpoint16_1Input) => Effect.Effect<Endpoint16_1Output, E>
|
||||
|
||||
export interface FileApi<E = never> {
|
||||
readonly list: FileListOperation<E>
|
||||
readonly get: FileGetOperation<E>
|
||||
readonly find: FileFindOperation<E>
|
||||
}
|
||||
|
||||
export type Endpoint17_0Input = {
|
||||
|
||||
@@ -1034,12 +1034,12 @@ const Endpoint16_0 = (raw: RawClient["server.fs"]) => (input?: Endpoint16_0Input
|
||||
|
||||
const Endpoint16_1 = (raw: RawClient["server.fs"]) => (input: Endpoint16_1Input) =>
|
||||
preserveEffect<Endpoint16_1Output>()(
|
||||
raw["fs.get"]({
|
||||
raw["fs.find"]({
|
||||
query: { location: input["location"], query: input["query"], type: input["type"], limit: input["limit"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const adaptGroup16 = (raw: RawClient["server.fs"]) => ({ list: Endpoint16_0(raw), get: Endpoint16_1(raw) })
|
||||
const adaptGroup16 = (raw: RawClient["server.fs"]) => ({ list: Endpoint16_0(raw), find: Endpoint16_1(raw) })
|
||||
|
||||
const Endpoint17_0 = (raw: RawClient["server.command"]) => (input?: Endpoint17_0Input) =>
|
||||
preserveEffect<Endpoint17_0Output>()(
|
||||
|
||||
@@ -167,8 +167,8 @@ import type {
|
||||
FileReadOutput,
|
||||
FileListInput,
|
||||
FileListOutput,
|
||||
FileGetInput,
|
||||
FileGetOutput,
|
||||
FileFindInput,
|
||||
FileFindOutput,
|
||||
CommandListInput,
|
||||
CommandListOutput,
|
||||
SkillListInput,
|
||||
@@ -1473,8 +1473,8 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
get: (input: FileGetInput, requestOptions?: RequestOptions) =>
|
||||
request<FileGetOutput>(
|
||||
find: (input: FileFindInput, requestOptions?: RequestOptions) =>
|
||||
request<FileFindOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/fs/find`,
|
||||
|
||||
@@ -837,6 +837,16 @@ export type ProjectDirectoriesUpdated = {
|
||||
data: { projectID: string }
|
||||
}
|
||||
|
||||
export type ProjectDirectoryResolved = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "project.directory.resolved"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { projectID: string; directory: string; previous: string }
|
||||
}
|
||||
|
||||
export type CommandUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -2093,6 +2103,7 @@ export type V2Event =
|
||||
| PluginAdded
|
||||
| PluginUpdated
|
||||
| ProjectDirectoriesUpdated
|
||||
| ProjectDirectoryResolved
|
||||
| CommandUpdated
|
||||
| ConfigUpdated
|
||||
| SkillUpdated
|
||||
@@ -5335,7 +5346,7 @@ export type FileListOutput = {
|
||||
data: Array<FileSystemEntry>
|
||||
}
|
||||
|
||||
export type FileGetInput = {
|
||||
export type FileFindInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly query: string
|
||||
@@ -5362,7 +5373,7 @@ export type FileGetInput = {
|
||||
}["limit"]
|
||||
}
|
||||
|
||||
export type FileGetOutput = {
|
||||
export type FileFindOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
data: Array<FileSystemEntry>
|
||||
}
|
||||
|
||||
@@ -19,6 +19,12 @@
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"imports": {
|
||||
"#transpile": {
|
||||
"workerd": "./src/interpreter/transpile.workerd.ts",
|
||||
"default": "./src/interpreter/transpile.node.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "bun run script/build.ts",
|
||||
"typecheck": "tsgo --noEmit",
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { parse } from "acorn"
|
||||
import { Cause, Effect, Scope } from "effect"
|
||||
import { DiagnosticCategory, ModuleKind, ScriptTarget, flattenDiagnosticMessageText, transpileModule } from "typescript"
|
||||
// #transpile: conditional import — full typescript on node/bun, an identity
|
||||
// pass-through on workerd (the compiler is ~11 MiB and can't init there).
|
||||
import { transpile } from "#transpile"
|
||||
import type { DataValue, Diagnostic, ExecuteOptions, ResolvedExecutionLimits, Result } from "../codemode.js"
|
||||
import { copyIn, copyOut, ToolRuntime, type Services } from "../tool-runtime.js"
|
||||
import type { Tools } from "../tools.js"
|
||||
@@ -119,21 +121,10 @@ export const executeWithLimits = <const Provided extends Record<string, unknown>
|
||||
}
|
||||
|
||||
const parseProgram = (code: string): ProgramNode => {
|
||||
const transpiled = transpileModule(`async function __codemode__() {\n${code}\n}`, {
|
||||
reportDiagnostics: true,
|
||||
compilerOptions: {
|
||||
target: ScriptTarget.ESNext,
|
||||
module: ModuleKind.ESNext,
|
||||
},
|
||||
})
|
||||
const diagnostic = transpiled.diagnostics?.find((item) => item.category === DiagnosticCategory.Error)
|
||||
const transpiled = transpile(`async function __codemode__() {\n${code}\n}`)
|
||||
|
||||
if (diagnostic) {
|
||||
throw new InterpreterRuntimeError(
|
||||
`Failed to parse TypeScript: ${flattenDiagnosticMessageText(diagnostic.messageText, "\n")}`,
|
||||
undefined,
|
||||
"ParseError",
|
||||
)
|
||||
if (transpiled.error !== undefined) {
|
||||
throw new InterpreterRuntimeError(`Failed to parse TypeScript: ${transpiled.error}`, undefined, "ParseError")
|
||||
}
|
||||
|
||||
const bodyStart = transpiled.outputText.indexOf("{") + 1
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { DiagnosticCategory, ModuleKind, ScriptTarget, flattenDiagnosticMessageText, transpileModule } from "typescript"
|
||||
|
||||
export interface TranspileResult {
|
||||
readonly outputText: string
|
||||
readonly error?: string
|
||||
}
|
||||
|
||||
// Full TypeScript transpilation on node/bun runtimes.
|
||||
export const transpile = (source: string): TranspileResult => {
|
||||
const transpiled = transpileModule(source, {
|
||||
reportDiagnostics: true,
|
||||
compilerOptions: {
|
||||
target: ScriptTarget.ESNext,
|
||||
module: ModuleKind.ESNext,
|
||||
},
|
||||
})
|
||||
const diagnostic = transpiled.diagnostics?.find((item) => item.category === DiagnosticCategory.Error)
|
||||
if (diagnostic) {
|
||||
return {
|
||||
outputText: transpiled.outputText,
|
||||
error: flattenDiagnosticMessageText(diagnostic.messageText, "\n"),
|
||||
}
|
||||
}
|
||||
return { outputText: transpiled.outputText }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export interface TranspileResult {
|
||||
readonly outputText: string
|
||||
readonly error?: string
|
||||
}
|
||||
|
||||
// workerd profile: the typescript compiler is ~11 MiB and probes node
|
||||
// internals at module init, so codemode programs are passed through
|
||||
// untranspiled. Plain-JS programs (the overwhelmingly common case) parse
|
||||
// fine downstream via acorn; TypeScript-only syntax surfaces as a parse
|
||||
// error from the interpreter instead of a transpile diagnostic.
|
||||
export const transpile = (source: string): TranspileResult => ({ outputText: source })
|
||||
@@ -6866,7 +6866,7 @@
|
||||
"/api/fs/find": {
|
||||
"get": {
|
||||
"tags": ["filesystem"],
|
||||
"operationId": "v2.fs.get",
|
||||
"operationId": "v2.fs.find",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "location",
|
||||
|
||||
@@ -122,7 +122,10 @@ const layer = Layer.effect(
|
||||
return { id: info?.id ?? defaultID, info }
|
||||
}),
|
||||
list: Effect.fn("Agent.list")(function* () {
|
||||
return Array.fromIterable(state.get().agents.values())
|
||||
const agents = Array.fromIterable(state.get().agents.values())
|
||||
const selected = selectedDefault()
|
||||
if (!selected) return agents
|
||||
return [selected, ...agents.filter((agent) => agent.id !== selected.id)]
|
||||
}),
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { EventLog } from "@opencode-ai/schema/event-log"
|
||||
import { and, asc, eq, gt, lte, sql } from "drizzle-orm"
|
||||
import { Database } from "./database/database.js"
|
||||
import { EventSequenceTable, EventTable } from "./event/sql.js"
|
||||
import { Location } from "./location.js"
|
||||
import type { Location } from "@opencode-ai/schema/location"
|
||||
import { KeyedMutex } from "./effect/keyed-mutex.js"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { isDeepStrictEqual } from "node:util"
|
||||
@@ -183,6 +183,9 @@ export function configured(options?: Options) {
|
||||
layer: Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
// Deferred import: a static one would close the module cycle
|
||||
// bus → location → project → bus and hit the node bindings in TDZ.
|
||||
const { Location } = yield* Effect.promise(() => import("./location.js"))
|
||||
const pubsub = {
|
||||
live: yield* PubSub.unbounded<Event.Payload>(),
|
||||
durable: new Map<string, Set<PubSub.PubSub<void>>>(),
|
||||
|
||||
@@ -2,9 +2,10 @@ import { createRequire } from "node:module"
|
||||
|
||||
declare const OPENCODE_LIBC: string | undefined
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
// Lazy: on workerd import.meta.url is undefined and the watcher is never
|
||||
// loaded, so createRequire must not run at module scope.
|
||||
export default function load() {
|
||||
const require = createRequire(import.meta.url)
|
||||
const libc = typeof OPENCODE_LIBC === "undefined" ? undefined : OPENCODE_LIBC
|
||||
return require(
|
||||
process.env.OPENCODE_PARCEL_WATCHER_PATH ??
|
||||
|
||||
@@ -99,6 +99,11 @@ export function merge(...rulesets: Permission.Ruleset[]): Permission.Ruleset {
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly allowsAll: (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly action: string
|
||||
readonly agent?: Agent.ID
|
||||
}) => Effect.Effect<boolean, SessionErrors.NotFoundError>
|
||||
readonly ask: (input: AssertInput) => Effect.Effect<AskResult, SessionErrors.NotFoundError>
|
||||
readonly assert: (input: AssertInput) => Effect.Effect<void, Error | SessionErrors.NotFoundError>
|
||||
readonly reply: (input: ReplyInput) => Effect.Effect<void, NotFoundError>
|
||||
@@ -154,6 +159,24 @@ const layer = Layer.effect(
|
||||
return agent?.permissions ?? missingAgentPermissions
|
||||
})
|
||||
|
||||
const allowsAll = Effect.fn("Permission.allowsAll")(function* (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly action: string
|
||||
readonly agent?: Agent.ID
|
||||
}) {
|
||||
const rules = yield* configured(input.sessionID, input.agent)
|
||||
const relevant = rules.filter((rule) => Wildcard.match(input.action, rule.action))
|
||||
for (let index = relevant.length - 1; index >= 0; index--) {
|
||||
const rule = relevant[index]
|
||||
if (rule.resource !== "*") {
|
||||
if (rule.effect !== "allow") return false
|
||||
continue
|
||||
}
|
||||
return rule.effect === "allow"
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
function denied(input: AssertInput, rules: Permission.Ruleset) {
|
||||
return input.resources.some((resource) => evaluate(input.action, resource, rules).effect === "deny")
|
||||
}
|
||||
@@ -315,7 +338,7 @@ const layer = Layer.effect(
|
||||
return Array.from(pending.values(), (item) => item.request).filter((request) => request.sessionID === sessionID)
|
||||
})
|
||||
|
||||
return Service.of({ ask, assert, reply, get, forSession, list })
|
||||
return Service.of({ allowsAll, ask, assert, reply, get, forSession, list })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -5,7 +5,9 @@ import { ChildProcess } from "effect/unstable/process"
|
||||
import { asc, desc } from "drizzle-orm"
|
||||
import path from "path"
|
||||
import { AbsolutePath } from "./schema.js"
|
||||
import { Bus } from "./bus.js"
|
||||
import { Database } from "./database/database.js"
|
||||
import { Event } from "@opencode-ai/schema/project-directories"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Git } from "./git.js"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
@@ -91,28 +93,40 @@ const layer = Layer.effect(
|
||||
const fs = yield* FSUtil.Service
|
||||
const git = yield* Git.Service
|
||||
const proc = yield* AppProcess.Service
|
||||
const bus = yield* Bus.Service
|
||||
const db = (yield* Database.Service).db
|
||||
const projectDirectories = yield* ProjectDirectories.Service
|
||||
|
||||
const announcing = new Set<string>()
|
||||
const persist = Effect.fnUntraced(function* (project: Resolved) {
|
||||
yield* db
|
||||
.transaction((tx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* upsertProject(tx, project)
|
||||
if (!project.vcs) return
|
||||
yield* projectDirectories.create({ projectID: project.id, directory: project.canonical }, tx)
|
||||
if (project.directory === project.canonical) return
|
||||
yield* projectDirectories.create(
|
||||
{
|
||||
projectID: project.id,
|
||||
directory: project.directory,
|
||||
strategy: project.vcs.type === "git" ? "git_worktree" : undefined,
|
||||
},
|
||||
tx,
|
||||
)
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
yield* upsertProject(db, project).pipe(Effect.orDie)
|
||||
if (!project.vcs) return project
|
||||
const directories: ProjectDirectories.CreateInput[] = [{ projectID: project.id, directory: project.canonical }]
|
||||
if (project.directory !== project.canonical)
|
||||
directories.push({
|
||||
projectID: project.id,
|
||||
directory: project.directory,
|
||||
strategy: project.vcs.type === "git" ? "git_worktree" : undefined,
|
||||
})
|
||||
// A missing directory row means this directory's resolution is a new durable
|
||||
// fact (copy.ts registers copy directories directly; those never strand
|
||||
// sessions and never announce). The row insert commits atomically with the
|
||||
// event, so a crash between checks retries on the next resolve instead of
|
||||
// stranding the announcement. The in-flight set keeps concurrent resolves
|
||||
// from publishing the same fact twice.
|
||||
for (const item of directories) {
|
||||
const key = item.projectID + "\u0000" + item.directory
|
||||
if (announcing.has(key)) continue
|
||||
announcing.add(key)
|
||||
yield* Effect.gen(function* () {
|
||||
if (yield* projectDirectories.get({ projectID: item.projectID, directory: item.directory })) return
|
||||
yield* bus.publish(
|
||||
Event.Resolved,
|
||||
{ projectID: item.projectID, directory: item.directory, previous: project.previous ?? ID.global },
|
||||
{ commit: () => Effect.asVoid(projectDirectories.create(item)) },
|
||||
)
|
||||
}).pipe(Effect.ensuring(Effect.sync(() => announcing.delete(key))))
|
||||
}
|
||||
return project
|
||||
})
|
||||
|
||||
@@ -250,5 +264,5 @@ const layer = Layer.effect(
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer: layer,
|
||||
deps: [Database.node, FSUtil.node, Git.node, AppProcess.node, ProjectDirectories.node],
|
||||
deps: [Bus.node, Database.node, FSUtil.node, Git.node, AppProcess.node, ProjectDirectories.node],
|
||||
})
|
||||
|
||||
@@ -748,7 +748,7 @@ const layer = Layer.effect(
|
||||
projectID: project.id,
|
||||
subpath: RelativePath.make(path.relative(project.directory, directory).replaceAll("\\", "/")),
|
||||
},
|
||||
delivery: input.delivery ?? "queue",
|
||||
delivery: input.delivery ?? "steer",
|
||||
})
|
||||
const inboxID = SessionMessage.ID.create()
|
||||
yield* SessionInbox.admit(db, bus, {
|
||||
|
||||
@@ -67,16 +67,17 @@ export const layer = Layer.effect(
|
||||
const releaseOnCommit = (sessionID: SessionSchema.ID) => ({
|
||||
commit: () => store.release(sessionID),
|
||||
})
|
||||
const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError, InterruptReason>({
|
||||
started: (sessionID) =>
|
||||
reportLifecycle(
|
||||
sessionID,
|
||||
bus.publish(SessionEvent.Execution.Started, { sessionID }, claimOnCommit(sessionID)),
|
||||
),
|
||||
drain: Effect.fnUntraced(function* (sessionID: SessionSchema.ID, force) {
|
||||
function drain(
|
||||
sessionID: SessionSchema.ID,
|
||||
force: boolean,
|
||||
continuation?: SessionRunner.Continuation,
|
||||
): Effect.Effect<void, SessionRunner.RunError> {
|
||||
return Effect.gen(function* () {
|
||||
const session = yield* store.get(sessionID)
|
||||
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
|
||||
return yield* SessionRunner.Service.use((runner) => runner.drain({ sessionID, force })).pipe(
|
||||
const result = yield* SessionRunner.Service.use((runner) =>
|
||||
runner.drain({ sessionID, force, continuation }),
|
||||
).pipe(
|
||||
Effect.provide(locations.get(session.location)),
|
||||
Effect.tapCause((cause) =>
|
||||
Cause.hasInterruptsOnly(cause)
|
||||
@@ -84,7 +85,17 @@ export const layer = Layer.effect(
|
||||
: Effect.logError("Failed to drain Session", cause).pipe(Effect.annotateLogs({ sessionID })),
|
||||
),
|
||||
)
|
||||
}),
|
||||
if (result.type === "complete") return
|
||||
return yield* drain(sessionID, false, result.continuation)
|
||||
})
|
||||
}
|
||||
const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError, InterruptReason>({
|
||||
started: (sessionID) =>
|
||||
reportLifecycle(
|
||||
sessionID,
|
||||
bus.publish(SessionEvent.Execution.Started, { sessionID }, claimOnCommit(sessionID)),
|
||||
),
|
||||
drain: (sessionID, force) => drain(sessionID, force),
|
||||
// One terminal observation per busy period, covering every coalesced drain.
|
||||
settled: (sessionID, exit, reason) =>
|
||||
reportLifecycle(
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
export * as SessionProjector from "./projector.js"
|
||||
|
||||
import { and, asc, desc, eq, gt, gte, lt, lte, sql } from "drizzle-orm"
|
||||
import { and, asc, desc, eq, gt, gte, inArray, lt, lte, sql } from "drizzle-orm"
|
||||
import { DateTime, Effect, Layer, Schema, Stream } from "effect"
|
||||
import path from "path"
|
||||
import { Database } from "../database/database.js"
|
||||
import { Bus } from "../bus.js"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
@@ -15,7 +16,10 @@ import { Workspace } from "../workspace.js"
|
||||
import { InstructionState } from "./instruction-state.js"
|
||||
import { SessionInboxTable, SessionMessageTable, SessionTable } from "./sql.js"
|
||||
import { Slug } from "../util/slug.js"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Event } from "@opencode-ai/schema/project-directories"
|
||||
import { Project } from "@opencode-ai/schema/project"
|
||||
import { AbsolutePath, RelativePath } from "../schema.js"
|
||||
import type { SessionSchema } from "./schema.js"
|
||||
|
||||
@@ -435,6 +439,47 @@ const layer = Layer.effectDiscard(
|
||||
yield* InstructionState.reset(db, event.data.sessionID)
|
||||
}),
|
||||
)
|
||||
// Sessions whose ownership came from the directory's previous resolution
|
||||
// follow its new identity. Location, transcript, instructions, and recency
|
||||
// are untouched: the session did not move, its directory got identified.
|
||||
yield* bus.project(Event.Resolved, (event) =>
|
||||
Effect.gen(function* () {
|
||||
const stale = [event.data.previous, Project.ID.global].filter((id) => id !== event.data.projectID)
|
||||
if (stale.length === 0) return
|
||||
const rows = yield* db
|
||||
.select({ id: SessionTable.id, directory: SessionTable.directory })
|
||||
.from(SessionTable)
|
||||
.where(
|
||||
and(
|
||||
inArray(SessionTable.project_id, stale),
|
||||
// Lexicographic range narrows the scan to prefix neighbors without
|
||||
// LIKE escaping; FSUtil.contains below decides containment exactly.
|
||||
gte(SessionTable.directory, event.data.directory),
|
||||
lte(SessionTable.directory, AbsolutePath.make(event.data.directory + "\uffff")),
|
||||
),
|
||||
)
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
yield* Effect.forEach(
|
||||
rows,
|
||||
(row) => {
|
||||
if (!FSUtil.contains(event.data.directory, row.directory)) return Effect.void
|
||||
return db
|
||||
.update(SessionTable)
|
||||
.set({
|
||||
project_id: event.data.projectID,
|
||||
path: RelativePath.make(path.relative(event.data.directory, row.directory).replaceAll("\\", "/")),
|
||||
// Self-assignment suppresses the column's $onUpdate: adoption is not activity.
|
||||
time_updated: sql`${SessionTable.time_updated}`,
|
||||
})
|
||||
.where(eq(SessionTable.id, row.id))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
},
|
||||
{ discard: true },
|
||||
)
|
||||
}),
|
||||
)
|
||||
yield* bus.project(SessionEvent.Deleted, (event) =>
|
||||
db.delete(SessionTable).where(eq(SessionTable.id, event.data.sessionID)).run().pipe(Effect.orDie),
|
||||
)
|
||||
|
||||
@@ -16,13 +16,20 @@ export type RunError =
|
||||
| UserInterruptedError
|
||||
| Instructions.InitializationBlocked
|
||||
|
||||
export type Continuation = { readonly step: number }
|
||||
|
||||
export type DrainResult =
|
||||
| { readonly type: "complete" }
|
||||
| { readonly type: "moved"; readonly continuation?: Continuation }
|
||||
|
||||
/** Runs one local continuation from already-recorded Session history. */
|
||||
export interface Interface {
|
||||
/** Drains eligible durable work. Explicit runs make one model call even when no work is eligible. */
|
||||
/** Drains eligible durable work, returning transient state when execution must continue at a new Location. */
|
||||
readonly drain: (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly force: boolean
|
||||
}) => Effect.Effect<void, RunError>
|
||||
readonly continuation?: Continuation
|
||||
}) => Effect.Effect<DrainResult, RunError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionRunner") {}
|
||||
|
||||
@@ -23,7 +23,7 @@ import { SessionMessage } from "../message.js"
|
||||
import { SessionSchema } from "../schema.js"
|
||||
import { SessionStore } from "../store.js"
|
||||
import { SessionTitle } from "../title.js"
|
||||
import { Service } from "./index.js"
|
||||
import { Service, type Continuation } from "./index.js"
|
||||
import { createLLMEventPublisher, type StepRecord } from "./publish-llm-event.js"
|
||||
import { Snapshot } from "../../snapshot.js"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
@@ -124,19 +124,25 @@ const layer = Layer.effect(
|
||||
const drain = Effect.fn("SessionRunner.drain")(function* (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly force: boolean
|
||||
readonly continuation?: Continuation
|
||||
}) {
|
||||
let force = input.force
|
||||
if (!force && !(yield* SessionInbox.has(db, input.sessionID, "any"))) return
|
||||
let continuation = input.continuation
|
||||
if (!force && !continuation && !(yield* SessionInbox.has(db, input.sessionID, "any")))
|
||||
return { type: "complete" as const }
|
||||
yield* settleStaleToolCalls(input.sessionID)
|
||||
while (true) {
|
||||
if (yield* runPendingCompaction(input.sessionID)) {
|
||||
force = false
|
||||
continue
|
||||
}
|
||||
if (yield* runPendingMove(input.sessionID)) return
|
||||
if (!force && !(yield* SessionInbox.has(db, input.sessionID, "input"))) return
|
||||
if (yield* runSteps(input.sessionID)) return
|
||||
if (yield* runPendingMove(input.sessionID, "input")) return { type: "moved" as const }
|
||||
if (!force && !continuation && !(yield* SessionInbox.has(db, input.sessionID, "input")))
|
||||
return { type: "complete" as const }
|
||||
const result = yield* runSteps(input.sessionID, continuation)
|
||||
if (result.type === "moved") return result
|
||||
force = false
|
||||
continuation = undefined
|
||||
}
|
||||
})
|
||||
|
||||
@@ -144,15 +150,21 @@ const layer = Layer.effect(
|
||||
* Runs logical steps until no tool result or newly admitted steer requires another
|
||||
* model call. Queued inputs remain pending until the current model work reaches idle.
|
||||
*/
|
||||
const runSteps = Effect.fn("SessionRunner.runSteps")(function* (sessionID: SessionSchema.ID) {
|
||||
const runSteps = Effect.fn("SessionRunner.runSteps")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
continuation?: Continuation,
|
||||
) {
|
||||
// Fresh work may promote queued input; later steps absorb steers only.
|
||||
let promotable: SessionInbox.Promotable = "input"
|
||||
let step = 1
|
||||
let promotable: SessionInbox.Promotable = continuation ? "steer" : "input"
|
||||
let step = continuation?.step ?? 1
|
||||
let next = continuation
|
||||
while (true) {
|
||||
if (yield* runPendingCompaction(sessionID)) continue
|
||||
if (yield* runPendingMove(sessionID)) return true
|
||||
if (yield* runPendingMove(sessionID, "steer")) return { type: "moved" as const, continuation: next }
|
||||
const result = yield* runStep(sessionID, promotable, step)
|
||||
if (!result.needsContinuation && !(yield* SessionInbox.has(db, sessionID, "steer"))) return false
|
||||
next = result.needsContinuation ? { step: result.step + 1 } : undefined
|
||||
if (!result.needsContinuation && !(yield* SessionInbox.has(db, sessionID, "steer")))
|
||||
return { type: "complete" as const }
|
||||
promotable = "steer"
|
||||
step = result.step + 1
|
||||
}
|
||||
@@ -530,12 +542,16 @@ const layer = Layer.effect(
|
||||
)
|
||||
})
|
||||
|
||||
const runPendingMove = Effect.fn("SessionRunner.runPendingMove")(function* (sessionID: SessionSchema.ID) {
|
||||
const runPendingMove = Effect.fn("SessionRunner.runPendingMove")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
promotable: SessionInbox.Promotable,
|
||||
) {
|
||||
return yield* SessionInbox.serialized(
|
||||
sessionID,
|
||||
Effect.gen(function* () {
|
||||
const pending =
|
||||
(yield* SessionInbox.nextSteer(db, sessionID)) ?? (yield* SessionInbox.nextQueued(db, sessionID))
|
||||
(yield* SessionInbox.nextSteer(db, sessionID)) ??
|
||||
(promotable === "input" ? yield* SessionInbox.nextQueued(db, sessionID) : undefined)
|
||||
if (pending?.type !== "move") return false
|
||||
yield* bus.publishAll([
|
||||
[SessionEvent.InboxDelivered, { sessionID, inboxID: pending.id }],
|
||||
|
||||
@@ -149,36 +149,50 @@ export const Plugin = {
|
||||
(invocation) =>
|
||||
Effect.gen(function* () {
|
||||
const target = yield* mutation.resolve({ path: invocation.cwd, kind: "directory" })
|
||||
const parsed = yield* ShellParse.scan(invocation.command, invocation.shell, target.absolute)
|
||||
const directories = yield* Effect.forEach(parsed.directories, (directory) =>
|
||||
mutation.resolve({ path: path.resolve(target.absolute, directory), kind: "directory" }),
|
||||
)
|
||||
const unrestricted =
|
||||
(yield* permission.allowsAll({
|
||||
sessionID: context.sessionID,
|
||||
action: name,
|
||||
agent: context.agent,
|
||||
})) &&
|
||||
(yield* permission.allowsAll({
|
||||
sessionID: context.sessionID,
|
||||
action: "external_directory",
|
||||
agent: context.agent,
|
||||
}))
|
||||
invocation.cwd = target.absolute
|
||||
finalTimeout = invocation.timeout
|
||||
const external = [target, ...directories]
|
||||
.map((item) => item.externalDirectory)
|
||||
.filter((item) => item !== undefined)
|
||||
.filter(
|
||||
(item, index, items) => items.findIndex((other) => other.resource === item.resource) === index,
|
||||
if (!unrestricted) {
|
||||
const parsed = yield* ShellParse.scan(invocation.command, invocation.shell, target.absolute)
|
||||
const directories = yield* Effect.forEach(parsed.directories, (directory) =>
|
||||
mutation.resolve({ path: path.resolve(target.absolute, directory), kind: "directory" }),
|
||||
)
|
||||
if (external.length > 0)
|
||||
yield* permission.assert({
|
||||
action: "external_directory",
|
||||
resources: external.map((item) => item.resource),
|
||||
save: external.map((item) => item.save),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
if (parsed.commands.length > 0)
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
resources: parsed.commands.map((command) => command.resource),
|
||||
save: parsed.commands.map((command) => command.save),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
const external = [target, ...directories]
|
||||
.map((item) => item.externalDirectory)
|
||||
.filter((item) => item !== undefined)
|
||||
.filter(
|
||||
(item, index, items) =>
|
||||
items.findIndex((other) => other.resource === item.resource) === index,
|
||||
)
|
||||
if (external.length > 0)
|
||||
yield* permission.assert({
|
||||
action: "external_directory",
|
||||
resources: external.map((item) => item.resource),
|
||||
save: external.map((item) => item.save),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
if (parsed.commands.length > 0)
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
resources: parsed.commands.map((command) => command.resource),
|
||||
save: parsed.commands.map((command) => command.save),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
}
|
||||
const workdir = yield* Environment.typeFollowing(environment.files, target.absolute).pipe(
|
||||
Effect.catchTag("Environment.NotFound", () =>
|
||||
Effect.fail(new Error(`Working directory does not exist: ${target.absolute}`)),
|
||||
|
||||
@@ -55,7 +55,7 @@ export class RegistryService extends Context.Service<RegistryService, Registry>(
|
||||
|
||||
export const registry = (drivers: Readonly<Record<string, Interface>>): Registry => ({
|
||||
get: (provider) => {
|
||||
const driver = drivers[provider]
|
||||
const driver = Object.hasOwn(drivers, provider) ? drivers[provider] : undefined
|
||||
return driver ? Effect.succeed(driver) : Effect.fail(new ProviderNotFound({ provider }))
|
||||
},
|
||||
})
|
||||
|
||||
@@ -68,6 +68,26 @@ describe("Agent", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lists the selected default agent first", () =>
|
||||
Effect.gen(function* () {
|
||||
const agent = yield* Agent.Service
|
||||
yield* agent.transform((editor) => {
|
||||
editor.update(Agent.ID.make("build"), (info) => {
|
||||
info.mode = "primary"
|
||||
})
|
||||
editor.update(Agent.ID.make("reviewer"), (info) => {
|
||||
info.mode = "primary"
|
||||
})
|
||||
editor.update(Agent.ID.make("explore"), (info) => {
|
||||
info.mode = "subagent"
|
||||
})
|
||||
editor.default(Agent.ID.make("reviewer"))
|
||||
})
|
||||
|
||||
expect((yield* agent.list()).map((info) => String(info.id))).toEqual(["reviewer", "build", "explore"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rebuilds state when a transform is replaced", () =>
|
||||
Effect.gen(function* () {
|
||||
const agent = yield* Agent.Service
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
import { Effect, Layer } from "effect"
|
||||
|
||||
export const permissionLayer = (overrides: Partial<Permission.Interface> = {}) =>
|
||||
Layer.mock(Permission.Service, {
|
||||
allowsAll: () => Effect.succeed(false),
|
||||
...overrides,
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { Effect, Layer } from "effect"
|
||||
|
||||
export const globalProjectLayer = Layer.succeed(
|
||||
Project.Service,
|
||||
Project.Service.of({
|
||||
list: () => Effect.succeed([]),
|
||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
||||
directories: () => Effect.succeed([]),
|
||||
}),
|
||||
)
|
||||
@@ -112,6 +112,31 @@ describe("Permission", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("proves only unconditional configured allows", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Permission.Service
|
||||
const input = { sessionID: Session.ID.make("ses_test"), action: "shell" }
|
||||
|
||||
yield* setup([{ action: "shell", resource: "*", effect: "allow" }])
|
||||
expect(yield* service.allowsAll(input)).toBe(true)
|
||||
|
||||
yield* setRules([
|
||||
{ action: "shell", resource: "*", effect: "allow" },
|
||||
{ action: "shell", resource: "rm *", effect: "deny" },
|
||||
])
|
||||
expect(yield* service.allowsAll(input)).toBe(false)
|
||||
|
||||
yield* setRules([{ action: "shell", resource: "git *", effect: "allow" }])
|
||||
expect(yield* service.allowsAll(input)).toBe(false)
|
||||
|
||||
yield* setRules([
|
||||
{ action: "shell", resource: "rm *", effect: "deny" },
|
||||
{ action: "shell", resource: "*", effect: "allow" },
|
||||
])
|
||||
expect(yield* service.allowsAll(input)).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("evaluates against an explicit provider-turn agent", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup([{ action: "read", resource: "*", effect: "allow" }])
|
||||
|
||||
@@ -22,6 +22,7 @@ import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { DateTime, Effect, Layer, LayerMap, Stream } from "effect"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { globalProjectLayer } from "./lib/project"
|
||||
|
||||
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
|
||||
const model = LanguageModel.make({
|
||||
@@ -29,14 +30,6 @@ const model = LanguageModel.make({
|
||||
provider: "test",
|
||||
route: OpenAIChat.route.with({ limits: { context: 10_000, output: 1_000 } }),
|
||||
})
|
||||
const projects = Layer.succeed(
|
||||
Project.Service,
|
||||
Project.Service.of({
|
||||
list: () => Effect.succeed([]),
|
||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
||||
directories: () => Effect.succeed([]),
|
||||
}),
|
||||
)
|
||||
let requests: LLMRequest[] = []
|
||||
const client = Layer.mock(LLMClient.Service)({
|
||||
stream: (request: LLMRequest) => {
|
||||
@@ -73,7 +66,7 @@ const it = testEffect(
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
|
||||
[
|
||||
[LocationServiceMap.node, locations],
|
||||
[Project.node, projects],
|
||||
[Project.node, globalProjectLayer],
|
||||
[SessionExecution.node, SessionExecution.noopLayer],
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { $ } from "bun"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { DateTime, Effect, Layer, Stream } from "effect"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
@@ -7,6 +9,7 @@ import { asc, eq } from "drizzle-orm"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Hash } from "@opencode-ai/util/hash"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { EventTable } from "@opencode-ai/core/event/sql"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
@@ -26,16 +29,9 @@ import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { SessionTransfer } from "@opencode-ai/core/session/transfer"
|
||||
import { Workspace } from "@opencode-ai/core/workspace"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { globalProjectLayer } from "./lib/project"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
|
||||
const projects = Layer.succeed(
|
||||
Project.Service,
|
||||
Project.Service.of({
|
||||
list: () => Effect.succeed([]),
|
||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
||||
directories: () => Effect.succeed([]),
|
||||
}),
|
||||
)
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([
|
||||
@@ -48,7 +44,16 @@ const it = testEffect(
|
||||
]),
|
||||
[
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
[Project.node, projects],
|
||||
[Project.node, globalProjectLayer],
|
||||
[SessionExecution.node, SessionExecution.noopLayer],
|
||||
],
|
||||
),
|
||||
)
|
||||
const liveIt = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, Project.node, SessionProjector.node, SessionStore.node, Session.node]),
|
||||
[
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
[SessionExecution.node, SessionExecution.noopLayer],
|
||||
],
|
||||
),
|
||||
@@ -78,6 +83,65 @@ function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
|
||||
}
|
||||
|
||||
describe("Session.create", () => {
|
||||
liveIt.live("follows the directory's project identity established after creation", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const projects = yield* Project.Service
|
||||
const { db } = yield* Database.Service
|
||||
const ref = Location.Ref.make({ directory: AbsolutePath.make(directory) })
|
||||
const nested = Location.Ref.make({ directory: AbsolutePath.make(path.join(directory, "packages", "app")) })
|
||||
const created = yield* session.create({ location: ref, title: "Before git" })
|
||||
const child = yield* session.create({ location: nested, title: "Nested before git" })
|
||||
const originalUpdated = created.time.updated
|
||||
|
||||
yield* Effect.promise(async () => {
|
||||
await $`git init -q`.cwd(directory)
|
||||
await $`git config user.email test@example.com`.cwd(directory)
|
||||
await $`git config user.name Test`.cwd(directory)
|
||||
await fs.writeFile(path.join(directory, "README.md"), "test\n")
|
||||
await $`git add README.md`.cwd(directory)
|
||||
await $`git commit -qm initial`.cwd(directory)
|
||||
await $`git remote add origin git@github.com:owner/adopted.git`.cwd(directory)
|
||||
})
|
||||
|
||||
const project = yield* projects.resolve(ref.directory)
|
||||
const repeat = yield* projects.resolve(ref.directory)
|
||||
const adopted = yield* session.get(created.id)
|
||||
const nestedAdopted = yield* session.get(child.id)
|
||||
const page = yield* session.list({ project: project.id })
|
||||
const log = Array.from(yield* Stream.runCollect(logEvents(session, created.id)))
|
||||
|
||||
expect(created.projectID).toBe(Project.ID.global)
|
||||
expect(project.id).toBe(Project.ID.make(Hash.fast("git-remote:github.com/owner/adopted")))
|
||||
expect(repeat.id).toBe(project.id)
|
||||
expect(page.data.map((item) => item.id)).toEqual(expect.arrayContaining([created.id, child.id]))
|
||||
expect(adopted).toMatchObject({
|
||||
projectID: project.id,
|
||||
location: ref,
|
||||
subpath: undefined,
|
||||
time: { updated: originalUpdated },
|
||||
})
|
||||
expect(nestedAdopted).toMatchObject({
|
||||
projectID: project.id,
|
||||
location: nested,
|
||||
subpath: RelativePath.make("packages/app"),
|
||||
})
|
||||
// Adoption is a project-domain fact; the session log records nothing new.
|
||||
expect(log.map((event) => event.type)).toEqual(["session.created"])
|
||||
expect(yield* session.messages({ sessionID: created.id })).toEqual([])
|
||||
// Repeated resolution announces the directory's identity exactly once.
|
||||
const announced = yield* db
|
||||
.select({ type: EventTable.type })
|
||||
.from(EventTable)
|
||||
.where(eq(EventTable.aggregate_id, project.id))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
expect(announced.map((event) => event.type)).toEqual(["project.directory.resolved.1"])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("persists a missing title until one is generated or supplied", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
|
||||
@@ -346,14 +346,17 @@ function attempts(database: Database.Service["Service"], sessionID: Session.ID)
|
||||
/** Builds the local execution layer plus the restart actions against the test harness services. */
|
||||
function buildExecution(
|
||||
scope: Scope.Closeable,
|
||||
drain: SessionRunner.Interface["drain"],
|
||||
drain: (input: Parameters<SessionRunner.Interface["drain"]>[0]) => Effect.Effect<void, SessionRunner.RunError>,
|
||||
options?: SessionRestart.Options,
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
const bus = yield* Bus.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const runner = Layer.succeed(SessionRunner.Service, SessionRunner.Service.of({ drain }))
|
||||
const runner = Layer.succeed(
|
||||
SessionRunner.Service,
|
||||
SessionRunner.Service.of({ drain: (input) => drain(input).pipe(Effect.as({ type: "complete" as const })) }),
|
||||
)
|
||||
const locations = Layer.effect(
|
||||
LocationServiceMap.Service,
|
||||
LayerMap.make(
|
||||
|
||||
@@ -33,6 +33,8 @@ import { Tool } from "@opencode-ai/core/tool"
|
||||
import { tempLocationLayer } from "./fixture/location"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { permissionLayer } from "./lib/permission"
|
||||
import { globalProjectLayer } from "./lib/project"
|
||||
import { executeTool, registerToolPlugin } from "./lib/tool"
|
||||
|
||||
const readToolNode = makeLocationNode({
|
||||
@@ -50,25 +52,7 @@ const readToolNode = makeLocationNode({
|
||||
],
|
||||
})
|
||||
|
||||
const projects = Layer.succeed(
|
||||
Project.Service,
|
||||
Project.Service.of({
|
||||
list: () => Effect.succeed([]),
|
||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
||||
directories: () => Effect.succeed([]),
|
||||
}),
|
||||
)
|
||||
const permission = Layer.succeed(
|
||||
Permission.Service,
|
||||
Permission.Service.of({
|
||||
assert: () => Effect.void,
|
||||
ask: () => Effect.die("unused"),
|
||||
reply: () => Effect.die("unused"),
|
||||
get: () => Effect.die("unused"),
|
||||
forSession: () => Effect.die("unused"),
|
||||
list: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
const permission = permissionLayer({ assert: () => Effect.void })
|
||||
const config = Config.testLayer()
|
||||
const imageLayer = AppNodeBuilder.build(Image.node, [[Config.node, config]])
|
||||
|
||||
@@ -92,7 +76,7 @@ const testLayer = AppNodeBuilder.build(
|
||||
Image.node,
|
||||
]),
|
||||
[
|
||||
[Project.node, projects],
|
||||
[Project.node, globalProjectLayer],
|
||||
[SessionExecution.node, SessionExecution.noopLayer],
|
||||
[Location.node, tempLocationLayer],
|
||||
[Permission.node, permission],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Fiber, Layer, Schema, Stream } from "effect"
|
||||
import { Effect, Fiber, Schema, Stream } from "effect"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
@@ -16,21 +16,14 @@ import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { globalProjectLayer } from "./lib/project"
|
||||
|
||||
const projects = Layer.succeed(
|
||||
Project.Service,
|
||||
Project.Service.of({
|
||||
list: () => Effect.succeed([]),
|
||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
||||
directories: () => Effect.succeed([]),
|
||||
}),
|
||||
)
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
|
||||
[
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
[Project.node, projects],
|
||||
[Project.node, globalProjectLayer],
|
||||
[SessionExecution.node, SessionExecution.noopLayer],
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Effect } from "effect"
|
||||
import { Event } from "@opencode-ai/schema/project-directories"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
@@ -8,26 +9,20 @@ import { Location } from "@opencode-ai/core/location"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { globalProjectLayer } from "./lib/project"
|
||||
|
||||
const projects = Layer.succeed(
|
||||
Project.Service,
|
||||
Project.Service.of({
|
||||
list: () => Effect.succeed([]),
|
||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
||||
directories: () => Effect.succeed([]),
|
||||
}),
|
||||
)
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
|
||||
[
|
||||
[Project.node, projects],
|
||||
[Project.node, globalProjectLayer],
|
||||
[SessionExecution.node, SessionExecution.noopLayer],
|
||||
],
|
||||
),
|
||||
@@ -55,7 +50,7 @@ describe("Session.move", () => {
|
||||
expect(yield* session.inbox(created.id)).toMatchObject([
|
||||
{
|
||||
type: "move",
|
||||
delivery: "queue",
|
||||
delivery: "steer",
|
||||
payload: {
|
||||
location: { directory: destination },
|
||||
projectID: Project.ID.global,
|
||||
@@ -69,8 +64,45 @@ describe("Session.move", () => {
|
||||
const steered = yield* session.create({
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make(path.join(tmp.path, "other")) }),
|
||||
})
|
||||
yield* session.move({ sessionID: steered.id, directory: destination, delivery: "steer" })
|
||||
expect(yield* session.inbox(steered.id)).toMatchObject([{ type: "move", delivery: "steer" }])
|
||||
yield* session.move({ sessionID: steered.id, directory: destination, delivery: "queue" })
|
||||
expect(yield* session.inbox(steered.id)).toMatchObject([{ type: "move", delivery: "queue" }])
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("keeps a moved session out of its former directory's new identity", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const bus = yield* Bus.Service
|
||||
const previous = AbsolutePath.make(path.join(tmp.path, "previous"))
|
||||
const destination = AbsolutePath.make(tmp.path)
|
||||
const created = yield* session.create({ location: Location.Ref.make({ directory: previous }) })
|
||||
|
||||
// Moves are admitted through the inbox and applied by the drain;
|
||||
// publish the applied move directly since execution is a no-op here.
|
||||
yield* bus.publish(SessionEvent.Moved, {
|
||||
sessionID: created.id,
|
||||
location: Location.Ref.make({ directory: destination }),
|
||||
projectID: Project.ID.global,
|
||||
})
|
||||
// The former directory becomes a project after the session left it.
|
||||
yield* bus.publish(Event.Resolved, {
|
||||
projectID: Project.ID.make("adopting"),
|
||||
directory: previous,
|
||||
previous: Project.ID.global,
|
||||
})
|
||||
|
||||
expect(yield* session.get(created.id)).toMatchObject({
|
||||
projectID: Project.ID.global,
|
||||
location: { directory: destination },
|
||||
subpath: undefined,
|
||||
})
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Effect } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
@@ -12,20 +12,13 @@ import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { globalProjectLayer } from "./lib/project"
|
||||
|
||||
const projects = Layer.succeed(
|
||||
Project.Service,
|
||||
Project.Service.of({
|
||||
list: () => Effect.succeed([]),
|
||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
||||
directories: () => Effect.succeed([]),
|
||||
}),
|
||||
)
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
|
||||
[
|
||||
[Project.node, projects],
|
||||
[Project.node, globalProjectLayer],
|
||||
[SessionExecution.node, SessionExecution.noopLayer],
|
||||
],
|
||||
),
|
||||
|
||||
@@ -44,6 +44,7 @@ import { Effect, Layer, Stream } from "effect"
|
||||
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
||||
import path from "node:path"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { permissionLayer } from "./lib/permission"
|
||||
import { agentHost, catalogHost, host } from "./plugin/host"
|
||||
|
||||
const cassetteName = "session-runner/openai-chat-streams-text"
|
||||
@@ -55,17 +56,7 @@ if (process.env.RECORD === "true") {
|
||||
const cassette = HttpRecorder.layerFetch(cassetteName, { directory: cassetteDirectory })
|
||||
const executor = RequestExecutor.layer.pipe(Layer.provide(cassette))
|
||||
const client = LLMClient.layer.pipe(Layer.provide(executor))
|
||||
const permission = Layer.succeed(
|
||||
Permission.Service,
|
||||
Permission.Service.of({
|
||||
assert: () => Effect.die("unused"),
|
||||
ask: () => Effect.die("unused"),
|
||||
reply: () => Effect.die("unused"),
|
||||
get: () => Effect.die("unused"),
|
||||
forSession: () => Effect.die("unused"),
|
||||
list: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
const permission = permissionLayer()
|
||||
const model = OpenAIChat.route
|
||||
.with({
|
||||
endpoint: { baseURL: "https://api.openai.com/v1" },
|
||||
@@ -129,7 +120,7 @@ const execution = (llmClient: Layer.Layer<typeof LLMClient.Service>) =>
|
||||
Effect.gen(function* () {
|
||||
const sessionRunner = yield* SessionRunner.Service
|
||||
const coordinator = yield* SessionRunCoordinator.make<Session.ID, SessionRunner.RunError>({
|
||||
drain: (sessionID, force) => sessionRunner.drain({ sessionID, force }),
|
||||
drain: (sessionID, force) => sessionRunner.drain({ sessionID, force }).pipe(Effect.asVoid),
|
||||
})
|
||||
return SessionExecution.Service.of({
|
||||
active: coordinator.active,
|
||||
|
||||
@@ -74,6 +74,7 @@ import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Schema, Scope, S
|
||||
import { TestClock } from "effect/testing"
|
||||
import { asc, desc, eq } from "drizzle-orm"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { permissionLayer } from "./lib/permission"
|
||||
import { agentHost, catalogHost, host } from "./plugin/host"
|
||||
import PROMPT_DEFAULT from "../src/session/runner/prompt/base.txt"
|
||||
import { CodeModeInstructions } from "@opencode-ai/core/codemode/instructions"
|
||||
@@ -218,17 +219,7 @@ const permissionFail = {
|
||||
}),
|
||||
}),
|
||||
}
|
||||
const permission = Layer.succeed(
|
||||
Permission.Service,
|
||||
Permission.Service.of({
|
||||
assert: () => Effect.die("unused"),
|
||||
ask: () => Effect.die("unused"),
|
||||
reply: () => Effect.die("unused"),
|
||||
get: () => Effect.die("unused"),
|
||||
forSession: () => Effect.die("unused"),
|
||||
list: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
const permission = permissionLayer()
|
||||
const transformTools = (registry: Tool.Interface, tools: Readonly<Record<string, ToolInfo>>, options?: Tool.Options) =>
|
||||
registry.transform((draft) =>
|
||||
Object.entries(tools).forEach(([name, tool]) => draft.add({ ...tool, name, options: options ?? tool.options })),
|
||||
@@ -387,8 +378,21 @@ const execution = Layer.effect(
|
||||
SessionExecution.Service,
|
||||
Effect.gen(function* () {
|
||||
const sessionRunner = yield* SessionRunner.Service
|
||||
function drain(
|
||||
sessionID: Session.ID,
|
||||
force: boolean,
|
||||
continuation?: SessionRunner.Continuation,
|
||||
): Effect.Effect<void, SessionRunner.RunError> {
|
||||
return sessionRunner
|
||||
.drain({ sessionID, force, continuation })
|
||||
.pipe(
|
||||
Effect.flatMap((result) =>
|
||||
result.type === "complete" ? Effect.void : drain(sessionID, false, result.continuation),
|
||||
),
|
||||
)
|
||||
}
|
||||
const coordinator = yield* SessionRunCoordinator.make<Session.ID, SessionRunner.RunError>({
|
||||
drain: (sessionID, force) => sessionRunner.drain({ sessionID, force }),
|
||||
drain: (sessionID, force) => drain(sessionID, force),
|
||||
})
|
||||
return SessionExecution.Service.of({
|
||||
active: coordinator.active,
|
||||
@@ -1261,6 +1265,41 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves a tool continuation across a steered move", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
const bus = yield* Bus.Service
|
||||
const { db } = yield* Database.Service
|
||||
yield* admit(session, "Echo before moving")
|
||||
yield* TestLLM.push(
|
||||
TestLLM.tool("call-move", "echo", { text: "moving" }),
|
||||
TestLLM.text("Done", "text-after-move"),
|
||||
)
|
||||
const tools = yield* blockTools()
|
||||
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
yield* tools.started
|
||||
yield* SessionInbox.admit(db, bus, {
|
||||
id: SessionMessage.ID.create(),
|
||||
sessionID,
|
||||
item: {
|
||||
type: "move",
|
||||
payload: {
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make("/project") }),
|
||||
projectID: Project.ID.global,
|
||||
},
|
||||
delivery: "steer",
|
||||
},
|
||||
})
|
||||
|
||||
yield* tools.release
|
||||
yield* Fiber.join(run)
|
||||
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(requests.map(messageRoles).at(1)?.slice(0, 3)).toEqual(["user", "assistant", "tool"])
|
||||
expect(yield* session.inbox(sessionID)).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("seeds a fork with the parent's newest instruction values", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
|
||||
@@ -19,6 +19,7 @@ import { location } from "./fixture/location"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { permissionLayer } from "./lib/permission"
|
||||
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
|
||||
|
||||
const editToolNode = makeLocationNode({
|
||||
@@ -43,30 +44,22 @@ let denyAction: string | undefined
|
||||
let afterRead = (_target: string, _content: Uint8Array): Effect.Effect<void> => Effect.void
|
||||
let formatFile = (_target: string): Effect.Effect<boolean> => Effect.succeed(false)
|
||||
|
||||
const permission = Layer.succeed(
|
||||
Permission.Service,
|
||||
Permission.Service.of({
|
||||
assert: (input) =>
|
||||
Effect.sync(() => assertions.push(input)).pipe(
|
||||
Effect.andThen(
|
||||
input.action === denyAction
|
||||
? Effect.fail(
|
||||
new Permission.BlockedError({
|
||||
rules: [],
|
||||
permission: input.action,
|
||||
resources: input.resources,
|
||||
}),
|
||||
)
|
||||
: Effect.void,
|
||||
),
|
||||
const permission = permissionLayer({
|
||||
assert: (input) =>
|
||||
Effect.sync(() => assertions.push(input)).pipe(
|
||||
Effect.andThen(
|
||||
input.action === denyAction
|
||||
? Effect.fail(
|
||||
new Permission.BlockedError({
|
||||
rules: [],
|
||||
permission: input.action,
|
||||
resources: input.resources,
|
||||
}),
|
||||
)
|
||||
: Effect.void,
|
||||
),
|
||||
ask: () => Effect.die("unused"),
|
||||
reply: () => Effect.die("unused"),
|
||||
get: () => Effect.die("unused"),
|
||||
forSession: () => Effect.die("unused"),
|
||||
list: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
),
|
||||
})
|
||||
|
||||
const formatter = Layer.mock(Formatter.Service, {
|
||||
file: (target) => formatFile(target),
|
||||
|
||||
@@ -19,6 +19,7 @@ import { location } from "./fixture/location"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { permissionLayer } from "./lib/permission"
|
||||
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
|
||||
|
||||
const patchToolNode = makeLocationNode({
|
||||
@@ -38,34 +39,26 @@ let editApproved = false
|
||||
let afterEditApproval = (): Effect.Effect<void> => Effect.void
|
||||
let formatFile = (_target: string): Effect.Effect<boolean> => Effect.succeed(false)
|
||||
|
||||
const permission = Layer.succeed(
|
||||
Permission.Service,
|
||||
Permission.Service.of({
|
||||
assert: (input) =>
|
||||
Effect.sync(() => {
|
||||
assertions.push(input)
|
||||
if (input.action === "edit") editApproved = true
|
||||
}).pipe(
|
||||
Effect.andThen(input.action === "edit" ? Effect.suspend(afterEditApproval) : Effect.void),
|
||||
Effect.andThen(
|
||||
input.action === denyAction
|
||||
? Effect.fail(
|
||||
new Permission.BlockedError({
|
||||
rules: [],
|
||||
permission: input.action,
|
||||
resources: input.resources,
|
||||
}),
|
||||
)
|
||||
: Effect.void,
|
||||
),
|
||||
const permission = permissionLayer({
|
||||
assert: (input) =>
|
||||
Effect.sync(() => {
|
||||
assertions.push(input)
|
||||
if (input.action === "edit") editApproved = true
|
||||
}).pipe(
|
||||
Effect.andThen(input.action === "edit" ? Effect.suspend(afterEditApproval) : Effect.void),
|
||||
Effect.andThen(
|
||||
input.action === denyAction
|
||||
? Effect.fail(
|
||||
new Permission.BlockedError({
|
||||
rules: [],
|
||||
permission: input.action,
|
||||
resources: input.resources,
|
||||
}),
|
||||
)
|
||||
: Effect.void,
|
||||
),
|
||||
ask: () => Effect.die("unused"),
|
||||
reply: () => Effect.die("unused"),
|
||||
get: () => Effect.die("unused"),
|
||||
forSession: () => Effect.die("unused"),
|
||||
list: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
),
|
||||
})
|
||||
|
||||
const formatter = Layer.mock(Formatter.Service, {
|
||||
file: (target) => formatFile(target),
|
||||
|
||||
@@ -10,6 +10,7 @@ import { QuestionTool } from "@opencode-ai/core/tool/plugin/question"
|
||||
import { Image } from "@opencode-ai/core/image"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { imagePassthrough } from "./lib/image"
|
||||
import { permissionLayer } from "./lib/permission"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
|
||||
|
||||
@@ -28,30 +29,22 @@ const questionInput = {
|
||||
},
|
||||
],
|
||||
}
|
||||
const permission = Layer.succeed(
|
||||
Permission.Service,
|
||||
Permission.Service.of({
|
||||
assert: (input) =>
|
||||
Effect.sync(() => assertions.push(input)).pipe(
|
||||
Effect.andThen(
|
||||
deny
|
||||
? Effect.fail(
|
||||
new Permission.BlockedError({
|
||||
rules: [],
|
||||
permission: input.action,
|
||||
resources: input.resources,
|
||||
}),
|
||||
)
|
||||
: Effect.void,
|
||||
),
|
||||
const permission = permissionLayer({
|
||||
assert: (input) =>
|
||||
Effect.sync(() => assertions.push(input)).pipe(
|
||||
Effect.andThen(
|
||||
deny
|
||||
? Effect.fail(
|
||||
new Permission.BlockedError({
|
||||
rules: [],
|
||||
permission: input.action,
|
||||
resources: input.resources,
|
||||
}),
|
||||
)
|
||||
: Effect.void,
|
||||
),
|
||||
ask: () => Effect.die("unused"),
|
||||
reply: () => Effect.die("unused"),
|
||||
get: () => Effect.die("unused"),
|
||||
forSession: () => Effect.die("unused"),
|
||||
list: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
),
|
||||
})
|
||||
const form = Layer.succeed(
|
||||
Form.Service,
|
||||
Form.Service.of({
|
||||
|
||||
@@ -23,6 +23,7 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { SessionInstructions } from "@opencode-ai/core/session/instructions"
|
||||
import { Environment } from "@opencode-ai/core/environment/index"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { permissionLayer } from "./lib/permission"
|
||||
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
|
||||
|
||||
const readToolNode = makeLocationNode({
|
||||
@@ -70,32 +71,24 @@ const reader = Layer.succeed(
|
||||
}),
|
||||
)
|
||||
let allow = true
|
||||
const permission = Layer.succeed(
|
||||
Permission.Service,
|
||||
Permission.Service.of({
|
||||
assert: (input) =>
|
||||
Effect.sync(() => {
|
||||
assertions.push(input)
|
||||
}).pipe(
|
||||
Effect.andThen(
|
||||
allow
|
||||
? Effect.void
|
||||
: Effect.fail(
|
||||
new Permission.BlockedError({
|
||||
rules: [],
|
||||
permission: input.action,
|
||||
resources: input.resources,
|
||||
}),
|
||||
),
|
||||
),
|
||||
const permission = permissionLayer({
|
||||
assert: (input) =>
|
||||
Effect.sync(() => {
|
||||
assertions.push(input)
|
||||
}).pipe(
|
||||
Effect.andThen(
|
||||
allow
|
||||
? Effect.void
|
||||
: Effect.fail(
|
||||
new Permission.BlockedError({
|
||||
rules: [],
|
||||
permission: input.action,
|
||||
resources: input.resources,
|
||||
}),
|
||||
),
|
||||
),
|
||||
ask: () => Effect.die("unused"),
|
||||
reply: () => Effect.die("unused"),
|
||||
get: () => Effect.die("unused"),
|
||||
forSession: () => Effect.die("unused"),
|
||||
list: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
),
|
||||
})
|
||||
const config = Config.testLayer()
|
||||
const imageLayer = AppNodeBuilder.build(Image.node, [[Config.node, config]])
|
||||
const testFileSystem = Layer.effect(
|
||||
|
||||
@@ -19,6 +19,7 @@ import { Tool } from "@opencode-ai/core/tool"
|
||||
import { location } from "./fixture/location"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { permissionLayer } from "./lib/permission"
|
||||
import { executeTool, registerToolPlugin, toolIdentity } from "./lib/tool"
|
||||
|
||||
const globToolNode = makeLocationNode({
|
||||
@@ -49,20 +50,12 @@ const withTools = <A, E, R>(
|
||||
],
|
||||
[
|
||||
Permission.node,
|
||||
Layer.succeed(
|
||||
Permission.Service,
|
||||
Permission.Service.of({
|
||||
assert: (input) =>
|
||||
Effect.sync(() => {
|
||||
assertions?.push(input)
|
||||
}),
|
||||
ask: () => Effect.die("unused"),
|
||||
reply: () => Effect.die("unused"),
|
||||
get: () => Effect.die("unused"),
|
||||
forSession: () => Effect.die("unused"),
|
||||
list: () => Effect.die("unused"),
|
||||
}),
|
||||
),
|
||||
permissionLayer({
|
||||
assert: (input) =>
|
||||
Effect.sync(() => {
|
||||
assertions?.push(input)
|
||||
}),
|
||||
}),
|
||||
],
|
||||
]),
|
||||
),
|
||||
|
||||
@@ -39,42 +39,38 @@ import { Tool } from "@opencode-ai/core/tool"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { tempGlobalLayer } from "./fixture/global"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { permissionLayer } from "./lib/permission"
|
||||
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
|
||||
|
||||
const sessionID = Session.ID.make("ses_shell_tool_test")
|
||||
const sessionModel = Model.Ref.make({ id: Model.ID.make("test"), providerID: Provider.ID.make("test") })
|
||||
const assertions: Permission.AssertInput[] = []
|
||||
const allowedActions = new Set<string>()
|
||||
let denyAction: string | undefined
|
||||
let afterPermission = (_input: Permission.AssertInput): Effect.Effect<void> => Effect.void
|
||||
|
||||
const permission = Layer.succeed(
|
||||
Permission.Service,
|
||||
Permission.Service.of({
|
||||
assert: (input) =>
|
||||
Effect.sync(() => assertions.push(input)).pipe(
|
||||
Effect.andThen(Effect.suspend(() => afterPermission(input))),
|
||||
Effect.andThen(
|
||||
input.action === denyAction
|
||||
? Effect.fail(
|
||||
new Permission.BlockedError({
|
||||
rules: [],
|
||||
permission: input.action,
|
||||
resources: input.resources,
|
||||
}),
|
||||
)
|
||||
: Effect.void,
|
||||
),
|
||||
const permission = permissionLayer({
|
||||
allowsAll: (input) => Effect.succeed(allowedActions.has(input.action)),
|
||||
assert: (input) =>
|
||||
Effect.sync(() => assertions.push(input)).pipe(
|
||||
Effect.andThen(Effect.suspend(() => afterPermission(input))),
|
||||
Effect.andThen(
|
||||
input.action === denyAction
|
||||
? Effect.fail(
|
||||
new Permission.BlockedError({
|
||||
rules: [],
|
||||
permission: input.action,
|
||||
resources: input.resources,
|
||||
}),
|
||||
)
|
||||
: Effect.void,
|
||||
),
|
||||
ask: () => Effect.die("unused"),
|
||||
reply: () => Effect.die("unused"),
|
||||
get: () => Effect.die("unused"),
|
||||
forSession: () => Effect.die("unused"),
|
||||
list: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
),
|
||||
})
|
||||
|
||||
const reset = () => {
|
||||
assertions.length = 0
|
||||
allowedActions.clear()
|
||||
denyAction = undefined
|
||||
afterPermission = () => Effect.void
|
||||
}
|
||||
@@ -337,6 +333,30 @@ describe("ShellTool", () => {
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
|
||||
it.live(
|
||||
"skips command decomposition when shell and external directories are unrestricted",
|
||||
() =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
allowedActions.add("shell")
|
||||
allowedActions.add("external_directory")
|
||||
return withSession(tmp.path, (registry) =>
|
||||
executeTool(registry, call({ command: "printf one && printf two" }, "call-unrestricted")),
|
||||
).pipe(
|
||||
Effect.andThen(
|
||||
Effect.sync(() => {
|
||||
expect(assertions).toEqual([])
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
||||
),
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
|
||||
it.live(
|
||||
"captures stderr-only and mixed stdout/stderr output",
|
||||
() =>
|
||||
|
||||
@@ -14,6 +14,7 @@ import { tmpdir } from "./fixture/tmpdir"
|
||||
import { Image } from "@opencode-ai/core/image"
|
||||
import { it } from "./lib/effect"
|
||||
import { imagePassthrough } from "./lib/image"
|
||||
import { permissionLayer } from "./lib/permission"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
|
||||
@@ -52,30 +53,22 @@ describe("SkillTool", () => {
|
||||
let current = [info]
|
||||
const assertions: Permission.AssertInput[] = []
|
||||
let deny = false
|
||||
const permission = Layer.succeed(
|
||||
Permission.Service,
|
||||
Permission.Service.of({
|
||||
assert: (input) =>
|
||||
Effect.sync(() => assertions.push(input)).pipe(
|
||||
Effect.andThen(
|
||||
deny
|
||||
? Effect.fail(
|
||||
new Permission.BlockedError({
|
||||
rules: [],
|
||||
permission: input.action,
|
||||
resources: input.resources,
|
||||
}),
|
||||
)
|
||||
: Effect.void,
|
||||
),
|
||||
const permission = permissionLayer({
|
||||
assert: (input) =>
|
||||
Effect.sync(() => assertions.push(input)).pipe(
|
||||
Effect.andThen(
|
||||
deny
|
||||
? Effect.fail(
|
||||
new Permission.BlockedError({
|
||||
rules: [],
|
||||
permission: input.action,
|
||||
resources: input.resources,
|
||||
}),
|
||||
)
|
||||
: Effect.void,
|
||||
),
|
||||
ask: () => Effect.die("unused"),
|
||||
reply: () => Effect.die("unused"),
|
||||
get: () => Effect.die("unused"),
|
||||
forSession: () => Effect.die("unused"),
|
||||
list: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
),
|
||||
})
|
||||
const skills = Layer.succeed(
|
||||
Skill.Service,
|
||||
Skill.Service.of({
|
||||
|
||||
@@ -13,6 +13,7 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Image } from "@opencode-ai/core/image"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { imagePassthrough } from "./lib/image"
|
||||
import { permissionLayer } from "./lib/permission"
|
||||
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
|
||||
|
||||
const webFetchToolNode = makeLocationNode({
|
||||
@@ -36,17 +37,7 @@ const http = Layer.succeed(
|
||||
),
|
||||
),
|
||||
)
|
||||
const permission = Layer.succeed(
|
||||
Permission.Service,
|
||||
Permission.Service.of({
|
||||
assert: (input) => Effect.sync(() => assertions.push(input)),
|
||||
ask: () => Effect.die("unused"),
|
||||
reply: () => Effect.die("unused"),
|
||||
get: () => Effect.die("unused"),
|
||||
forSession: () => Effect.die("unused"),
|
||||
list: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
const permission = permissionLayer({ assert: (input) => Effect.sync(() => assertions.push(input)) })
|
||||
const toolLayer = (replacements: LayerNode.Replacements = []) =>
|
||||
AppNodeBuilder.build(LayerNode.group([Tool.node, webFetchToolNode]), [
|
||||
[Permission.node, permission],
|
||||
|
||||
@@ -15,6 +15,7 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Image } from "@opencode-ai/core/image"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { imagePassthrough } from "./lib/image"
|
||||
import { permissionLayer } from "./lib/permission"
|
||||
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
|
||||
import { webSearchHost } from "./plugin/host"
|
||||
|
||||
@@ -66,17 +67,9 @@ beforeEach(() => {
|
||||
})
|
||||
})
|
||||
|
||||
const permission = Layer.succeed(
|
||||
Permission.Service,
|
||||
Permission.Service.of({
|
||||
assert: (input) => Effect.sync(() => assertions.push(input)),
|
||||
ask: () => Effect.die("unused"),
|
||||
reply: () => Effect.die("unused"),
|
||||
get: () => Effect.die("unused"),
|
||||
forSession: () => Effect.die("unused"),
|
||||
list: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
const permission = permissionLayer({
|
||||
assert: (input) => Effect.sync(() => assertions.push(input)),
|
||||
})
|
||||
const websearch = Layer.succeed(
|
||||
WebSearch.Service,
|
||||
WebSearch.Service.of({
|
||||
|
||||
@@ -19,6 +19,7 @@ import { location } from "./fixture/location"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { permissionLayer } from "./lib/permission"
|
||||
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
|
||||
|
||||
const writeToolNode = makeLocationNode({
|
||||
@@ -33,30 +34,22 @@ const writes: string[] = []
|
||||
let formatFile = (_target: string): Effect.Effect<boolean> => Effect.succeed(false)
|
||||
let denyAction: string | undefined
|
||||
|
||||
const permission = Layer.succeed(
|
||||
Permission.Service,
|
||||
Permission.Service.of({
|
||||
assert: (input) =>
|
||||
Effect.sync(() => assertions.push(input)).pipe(
|
||||
Effect.andThen(
|
||||
input.action === denyAction
|
||||
? Effect.fail(
|
||||
new Permission.BlockedError({
|
||||
rules: [],
|
||||
permission: input.action,
|
||||
resources: input.resources,
|
||||
}),
|
||||
)
|
||||
: Effect.void,
|
||||
),
|
||||
const permission = permissionLayer({
|
||||
assert: (input) =>
|
||||
Effect.sync(() => assertions.push(input)).pipe(
|
||||
Effect.andThen(
|
||||
input.action === denyAction
|
||||
? Effect.fail(
|
||||
new Permission.BlockedError({
|
||||
rules: [],
|
||||
permission: input.action,
|
||||
resources: input.resources,
|
||||
}),
|
||||
)
|
||||
: Effect.void,
|
||||
),
|
||||
ask: () => Effect.die("unused"),
|
||||
reply: () => Effect.die("unused"),
|
||||
get: () => Effect.die("unused"),
|
||||
forSession: () => Effect.die("unused"),
|
||||
list: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
),
|
||||
})
|
||||
|
||||
const formatter = Layer.mock(Formatter.Service, {
|
||||
file: (target) => formatFile(target),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user