Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions packages/app/src/app/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ import { createSessionStore } from "./context/session";
import { createExtensionsStore } from "./context/extensions";
import { useGlobalSync } from "./context/global-sync";
import { createWorkspaceStore } from "./context/workspace";
import { createTargetStore, LocalTarget } from "./context/targets";
import {
updaterEnvironment,
readOpencodeConfig,
Expand Down Expand Up @@ -1321,6 +1322,25 @@ export default function App() {
engineRuntime,
});

const targetStore = createTargetStore();
const targetOptions = createMemo(() => [LocalTarget, ...targetStore.targets()]);
const activeTargetId = createMemo(() => targetStore.activeTargetId());
const defaultTargetId = createMemo(() => targetStore.defaultTargetId());
const activeSandbox = createMemo(() => {
const sandboxId = workspaceStore.activeSandboxId();
if (!sandboxId) return null;
return workspaceStore.sandboxes().find((sandbox) => sandbox.id === sandboxId) ?? null;
});
const activeTargetInfo = createMemo(() => workspaceStore.activeTargetInfo());

createEffect(() => {
const info = workspaceStore.activeTargetInfo();
if (!info) return;
if (info.type === "local") {
targetStore.setActiveTarget(LocalTarget.id);
}
});

createEffect(() => {
if (typeof window === "undefined") return;
const workspaceId = workspaceStore.activeWorkspaceId();
Expand Down Expand Up @@ -3236,6 +3256,38 @@ export default function App() {
}
}

const [targetSwitching, setTargetSwitching] = createSignal(false);

const handleSelectTarget = async (targetId: string) => {
if (targetSwitching()) return;
const target = targetOptions().find((option) => option.id === targetId);
if (!target) return;
if (targetStore.activeTargetId() === targetId) return;

setTargetSwitching(true);
try {
targetStore.setActiveTarget(targetId);
if (target.type === "remote") {
updateOpenworkServerSettings({
...openworkServerSettings(),
urlOverride: target.baseUrl ?? "",
token: target.token ?? undefined,
});
setStartupPreference("server");
targetStore.updateTarget(targetId, { lastUsedAt: Date.now() });
} else {
setStartupPreference("local");
}

const ok = await workspaceStore.createSandbox({ source: "base" });
if (ok) {
await createSessionAndOpen();
}
} finally {
setTargetSwitching(false);
}
};


onMount(async () => {
const startupPref = readStartupPreference();
Expand Down Expand Up @@ -3982,6 +4034,14 @@ export default function App() {
openwrkStatus: openwrkStatusState(),
owpenbotInfo: owpenbotInfoState(),
engineDoctorVersion: workspaceStore.engineDoctorResult()?.version ?? null,
targets: targetOptions(),
activeTargetId: activeTargetId(),
defaultTargetId: defaultTargetId(),
addTarget: targetStore.addTarget,
updateTarget: targetStore.updateTarget,
removeTarget: targetStore.removeTarget,
setDefaultTarget: targetStore.setDefaultTarget,
setActiveTarget: handleSelectTarget,
updateOpenworkServerSettings,
resetOpenworkServerSettings,
testOpenworkServerConnection,
Expand All @@ -3996,6 +4056,11 @@ export default function App() {
setWorkspacePickerOpen: workspaceStore.setWorkspacePickerOpen,
connectingWorkspaceId: workspaceStore.connectingWorkspaceId(),
workspaces: workspaceStore.workspaces(),
sandboxes: workspaceStore.sandboxes(),
activeSandboxId: workspaceStore.activeSandboxId(),
createSandbox: workspaceStore.createSandbox,
activateSandbox: workspaceStore.activateSandbox,
archiveSandbox: workspaceStore.archiveSandbox,
filteredWorkspaces: workspaceStore.filteredWorkspaces(),
activeWorkspaceId: workspaceStore.activeWorkspaceId(),
activateWorkspace: workspaceStore.activateWorkspace,
Expand Down Expand Up @@ -4180,6 +4245,8 @@ export default function App() {
activeWorkspaceRoot: workspaceStore.activeWorkspaceRoot().trim(),
workspaces: workspaceStore.workspaces(),
activeWorkspaceId: workspaceStore.activeWorkspaceId(),
activeSandbox: activeSandbox(),
activeTargetInfo: activeTargetInfo(),
connectingWorkspaceId: workspaceStore.connectingWorkspaceId(),
activateWorkspace: workspaceStore.activateWorkspace,
setWorkspaceSearch: workspaceStore.setWorkspaceSearch,
Expand Down Expand Up @@ -4246,6 +4313,9 @@ export default function App() {
providers: providers(),
providerConnectedIds: providerConnectedIds(),
listAgents: listAgents,
targetOptions: targetOptions(),
activeTargetId: activeTargetId(),
onSelectTarget: handleSelectTarget,
selectedSessionAgent: selectedSessionAgent(),
setSessionAgent: setSessionAgent,
saveSession: saveSessionExport,
Expand Down
46 changes: 46 additions & 0 deletions packages/app/src/app/components/sandbox-chip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type { OpenworkSandboxInfo, OpenworkTargetInfo } from "../lib/openwork-server";

import { Box, ChevronDown, Globe, HardDrive, Loader2 } from "lucide-solid";

const targetLabelFallback = (target: OpenworkTargetInfo | null) => {
if (!target) return "Target";
return target.type === "remote" ? "Remote" : "Local";
};

export default function SandboxChip(props: {
sandbox: OpenworkSandboxInfo | null;
target: OpenworkTargetInfo | null;
onClick: () => void;
connecting?: boolean;
}) {
const TargetIcon = props.target?.type === "remote" ? Globe : HardDrive;
const status = () => props.sandbox?.status ?? "active";
const targetLabel = () => props.target?.label?.trim() || targetLabelFallback(props.target);

return (
<button
onClick={props.onClick}
class="flex items-center gap-2 pl-3 pr-2 py-1.5 bg-gray-2 border border-gray-6 rounded-lg hover:border-gray-7 hover:bg-gray-4 transition-all group"
>
<div class="p-1 rounded bg-amber-7/10 text-amber-6">
<Box size={14} />
</div>
<div class="flex flex-col items-start mr-2 min-w-0">
<div class="flex items-center gap-2">
<span class="text-xs font-medium text-gray-12 leading-none truncate max-w-[9.5rem]">
{props.sandbox?.name ?? "Sandbox"}
</span>
<span class="text-[9px] uppercase tracking-wide px-1.5 py-0.5 rounded-full bg-gray-4 text-gray-11">
{status()}
</span>
</div>
<div class="flex items-center gap-1 text-[10px] text-gray-10 font-mono leading-none max-w-[140px] truncate">
<TargetIcon size={11} class="text-gray-8" />
<span class="truncate">{targetLabel()}</span>
</div>
</div>
<ChevronDown size={14} class="text-gray-10 group-hover:text-gray-11" />
{props.connecting ? <Loader2 size={14} class="text-gray-10 animate-spin" /> : null}
</button>
);
}
86 changes: 83 additions & 3 deletions packages/app/src/app/components/session/composer.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { For, Show, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js";
import type { Agent } from "@opencode-ai/sdk/v2/client";
import { ArrowRight, AtSign, ChevronDown, File, Paperclip, X, Zap } from "lucide-solid";
import { ArrowRight, AtSign, ChevronDown, File, HardDrive, Paperclip, X, Zap } from "lucide-solid";

import type { ComposerAttachment, ComposerDraft, ComposerPart, PromptMode } from "../../types";
import type { ComposerAttachment, ComposerDraft, ComposerPart, PromptMode, TargetProfile } from "../../types";

export type CommandItem = {
id: string;
Expand Down Expand Up @@ -55,6 +55,9 @@ type ComposerProps = {
isRemoteWorkspace: boolean;
attachmentsEnabled: boolean;
attachmentsDisabledReason: string | null;
targetOptions: TargetProfile[];
activeTargetId: string;
onSelectTarget: (targetId: string) => void | Promise<void>;
};

const MAX_ATTACHMENT_BYTES = 8 * 1024 * 1024;
Expand Down Expand Up @@ -276,8 +279,14 @@ export default function Composer(props: ComposerProps) {
const [historyIndex, setHistoryIndex] = createSignal({ prompt: -1, shell: -1 });
const [history, setHistory] = createSignal({ prompt: [] as ComposerDraft[], shell: [] as ComposerDraft[] });
const [variantMenuOpen, setVariantMenuOpen] = createSignal(false);
const [targetPickerOpen, setTargetPickerOpen] = createSignal(false);
let targetPickerRef: HTMLDivElement | undefined;
const activeVariant = createMemo(() => props.modelVariant ?? "none");
const attachmentsDisabled = createMemo(() => !props.attachmentsEnabled);
const activeTarget = createMemo(() => {
const current = props.targetOptions.find((option) => option.id === props.activeTargetId);
return current ?? props.targetOptions[0] ?? null;
});

onMount(() => {
queueMicrotask(() => focusEditorEnd());
Expand Down Expand Up @@ -816,6 +825,17 @@ export default function Composer(props: ComposerProps) {
onCleanup(() => window.removeEventListener("mousedown", handler));
});

createEffect(() => {
if (!targetPickerOpen()) return;
const handler = (event: MouseEvent) => {
if (!targetPickerRef) return;
if (targetPickerRef.contains(event.target as Node)) return;
setTargetPickerOpen(false);
};
window.addEventListener("mousedown", handler);
onCleanup(() => window.removeEventListener("mousedown", handler));
});

createEffect(() => {
const handler = () => {
editorRef?.focus();
Expand Down Expand Up @@ -1079,7 +1099,7 @@ export default function Composer(props: ComposerProps) {
class="bg-transparent border-none p-0 pb-12 pr-20 text-gray-12 focus:ring-0 text-[15px] leading-relaxed resize-none min-h-[24px] outline-none relative z-10"
/>

<div class="mt-3" ref={props.setAgentPickerRef}>
<div class="mt-3 flex flex-wrap items-center gap-2" ref={props.setAgentPickerRef}>
<button
type="button"
class="flex items-center gap-2 pl-3 pr-2 py-1.5 bg-gray-1/70 border border-gray-6 rounded-lg hover:border-gray-7 hover:bg-gray-3 transition-all group"
Expand Down Expand Up @@ -1155,6 +1175,66 @@ export default function Composer(props: ComposerProps) {
</div>
</div>
</Show>

<Show when={props.targetOptions.length}>
<div class="relative" ref={(el) => (targetPickerRef = el)}>
<button
type="button"
class="flex items-center gap-2 pl-3 pr-2 py-1.5 bg-gray-1/70 border border-gray-6 rounded-lg hover:border-gray-7 hover:bg-gray-3 transition-all group"
onClick={() => setTargetPickerOpen((open) => !open)}
aria-expanded={targetPickerOpen()}
>
<div class="p-1 rounded bg-gray-4 text-gray-10">
<HardDrive size={14} />
</div>
<div class="flex flex-col items-start mr-2 min-w-0">
<span class="text-xs font-medium text-gray-12 leading-none truncate max-w-[10rem]">
{activeTarget()?.label ?? "Run on"}
</span>
<span class="text-[10px] text-gray-10 font-mono leading-none">
{activeTarget()?.type === "remote" ? "Remote" : "Local"}
</span>
</div>
<ChevronDown size={14} class="text-gray-10 group-hover:text-gray-11" />
</button>

<Show when={targetPickerOpen()}>
<div class="absolute left-0 bottom-full mb-2 w-64 rounded-2xl border border-gray-6 bg-gray-1/95 shadow-2xl backdrop-blur-md overflow-hidden">
<div class="px-4 pt-3 pb-2 text-[10px] font-semibold uppercase tracking-[0.2em] text-gray-8 border-b border-gray-6/30">
Run on
</div>
<div class="max-h-64 overflow-auto p-2 space-y-1">
<For each={props.targetOptions}>
{(target) => (
<button
type="button"
class={`w-full flex items-center justify-between rounded-xl px-3 py-2 text-left text-xs transition-colors ${
props.activeTargetId === target.id
? "bg-gray-12/10 text-gray-12"
: "text-gray-11 hover:bg-gray-12/5"
}`}
onClick={() => {
props.onSelectTarget(target.id);
setTargetPickerOpen(false);
}}
>
<div class="min-w-0">
<div class="truncate">{target.label}</div>
<div class="text-[10px] text-gray-9">
{target.type === "remote" ? "Remote" : "Local"}
</div>
</div>
<Show when={props.activeTargetId === target.id}>
<span class="text-[10px] uppercase tracking-wider text-gray-9">Active</span>
</Show>
</button>
)}
</For>
</div>
</div>
</Show>
</div>
</Show>
</div>

<Show when={!props.prompt.trim() && !attachments().length}>
Expand Down
Loading
Loading