Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
4 changes: 2 additions & 2 deletions packages/app/src/components/prompt-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1449,7 +1449,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}

return (
<div class="relative size-full _max-h-[320px] flex flex-col gap-3">
<div class="relative size-full max-h-[320px] flex flex-col gap-3">
<Show when={store.popover}>
<div
class="absolute inset-x-0 -top-3 -translate-y-full origin-bottom-left max-h-80 min-h-10
Expand Down Expand Up @@ -1726,7 +1726,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
>
<Button
variant="ghost"
class="text-text-base _hidden group-hover/prompt-input:inline-block"
class="text-text-base hidden group-hover/prompt-input:inline-block"
onClick={() => local.model.variant.cycle()}
>
<span class="capitalize text-12-regular">{local.model.variant.current() ?? "Default"}</span>
Expand Down
29 changes: 29 additions & 0 deletions packages/app/src/context/global-sync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,14 +69,18 @@ function createGlobalSync() {
error?: InitError
path: Path
project: Project[]
skill: { name: string; description: string; location: string }[]
provider: ProviderListResponse
provider_auth: ProviderAuthResponse
selected_agent: Record<string, string>
}>({
ready: false,
path: { state: "", config: "", worktree: "", directory: "", home: "" },
project: [],
skill: [],
provider: { all: [], connected: [], default: {} },
provider_auth: {},
selected_agent: {},
})

const children: Record<string, ReturnType<typeof createStore<State>>> = {}
Expand Down Expand Up @@ -134,6 +138,22 @@ function createGlobalSync() {
})
}

async function loadSkills(directory?: string, sessionID?: string, agent?: string) {
globalSDK.client.skill
.list({ directory, sessionID, agent })
.then((x) => {
setGlobalStore("skill", reconcile(x.data ?? []))
})
.catch((err) => {
console.error("Failed to load skills", err)
})
}

function setSelectedAgent(directory: string, agent: string) {
setGlobalStore("selected_agent", directory, agent)
loadSkills(directory, undefined, agent)
}
Comment on lines 152 to 154

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The loadSkills call within setSelectedAgent is redundant. The only caller of this function, in packages/app/src/context/local.tsx, immediately calls globalSync.skill.load again with more specific parameters (including sessionID). This results in two network requests to load skills, where the second one's result overwrites the first. Removing this call will improve efficiency and make the function's behavior align better with its name (i.e., only setting the agent, without the side effect of loading data).

  function setSelectedAgent(directory: string, agent: string) {
    setGlobalStore("selected_agent", directory, agent)
  }


async function bootstrapInstance(directory: string) {
if (!directory) return
const [store, setStore] = child(directory)
Expand Down Expand Up @@ -434,6 +454,11 @@ function createGlobalSync() {
setGlobalStore("project", projects)
}),
),
retry(() =>
globalSDK.client.skill.list().then((x) => {
setGlobalStore("skill", x.data ?? [])
}),
),
retry(() =>
globalSDK.client.provider.list().then((x) => {
const data = x.data!
Expand Down Expand Up @@ -472,6 +497,10 @@ function createGlobalSync() {
},
child,
bootstrap,
skill: {
load: loadSkills,
setSelectedAgent,
},
project: {
loadSessions,
},
Expand Down
83 changes: 15 additions & 68 deletions packages/app/src/context/local.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { createStore, produce, reconcile } from "solid-js/store"
import { batch, createMemo } from "solid-js"
import { batch, createMemo, createEffect } from "solid-js"
import { useParams } from "@solidjs/router"
import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda"
import type { FileContent, FileNode, Model, Provider, File as FileStatus } from "@opencode-ai/sdk/v2"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useSDK } from "./sdk"
import { useSync } from "./sync"
import { useGlobalSync } from "./global-sync"
import { base64Encode } from "@opencode-ai/util/encode"
import { useProviders } from "@/hooks/use-providers"
import { DateTime } from "luxon"
Expand Down Expand Up @@ -42,6 +44,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const sdk = useSDK()
const sync = useSync()
const providers = useProviders()
const globalSync = useGlobalSync()
const params = useParams()

function isModelValid(model: ModelKey) {
const provider = providers.all().find((x) => x.id === model.providerID)
Expand Down Expand Up @@ -69,6 +73,15 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}>({
current: list()[0]?.name,
})

createEffect(() => {
const name = store.current
if (name) {
globalSync.skill.setSelectedAgent(sdk.directory, name)
globalSync.skill.load(sdk.directory, params.id, name)
}
})

return {
list,
current() {
Expand Down Expand Up @@ -320,62 +333,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const [store, setStore] = createStore<{
node: Record<string, LocalFile>
}>({
node: {}, // Object.fromEntries(sync.data.node.map((x) => [x.path, x])),
node: {},
})

// const changeset = createMemo(() => new Set(sync.data.changes.map((f) => f.path)))
// const changes = createMemo(() => Array.from(changeset()).sort((a, b) => a.localeCompare(b)))

// createEffect((prev: FileStatus[]) => {
// const removed = prev.filter((p) => !sync.data.changes.find((c) => c.path === p.path))
// for (const p of removed) {
// setStore(
// "node",
// p.path,
// produce((draft) => {
// draft.status = undefined
// draft.view = "raw"
// }),
// )
// load(p.path)
// }
// for (const p of sync.data.changes) {
// if (store.node[p.path] === undefined) {
// fetch(p.path).then(() => {
// if (store.node[p.path] === undefined) return
// setStore("node", p.path, "status", p)
// })
// } else {
// setStore("node", p.path, "status", p)
// }
// }
// return sync.data.changes
// }, sync.data.changes)

// const changed = (path: string) => {
// const node = store.node[path]
// if (node?.status) return true
// const set = changeset()
// if (set.has(path)) return true
// for (const p of set) {
// if (p.startsWith(path ? path + "/" : "")) return true
// }
// return false
// }

// const resetNode = (path: string) => {
// setStore("node", path, {
// loaded: undefined,
// pinned: undefined,
// content: undefined,
// selection: undefined,
// scrollTop: undefined,
// folded: undefined,
// view: undefined,
// selectedChange: undefined,
// })
// }

const relative = (path: string) => path.replace(sync.data.path.directory + "/", "")

const load = async (path: string) => {
Expand Down Expand Up @@ -420,17 +380,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const open = async (path: string, options?: { pinned?: boolean; view?: LocalFile["view"] }) => {
const relativePath = relative(path)
if (!store.node[relativePath]) await fetch(path)
// setStore("opened", (x) => {
// if (x.includes(relativePath)) return x
// return [
// ...opened()
// .filter((x) => x.pinned)
// .map((x) => x.path),
// relativePath,
// ]
// })
// setStore("active", relativePath)
// context.addActive()
if (options?.pinned) setStore("node", path, "pinned", true)
if (options?.view && store.node[relativePath].view === undefined) setStore("node", path, "view", options.view)
if (store.node[relativePath]?.loaded) return
Expand Down Expand Up @@ -522,8 +471,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
setChangeIndex(path: string, index: number | undefined) {
setStore("node", path, "selectedChange", index)
},
// changes,
// changed,
children(path: string) {
return Object.values(store.node).filter(
(x) =>
Expand Down
36 changes: 34 additions & 2 deletions packages/app/src/pages/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export default function Layout(props: ParentProps) {
lastSession: {} as { [directory: string]: string },
activeDraggable: undefined as string | undefined,
mobileProjectsExpanded: {} as Record<string, boolean>,
skillsExpanded: true,
})

const mobileProjects = {
Expand Down Expand Up @@ -509,9 +510,11 @@ export default function Layout(props: ParentProps) {
}

createEffect(() => {
if (!params.dir || !params.id) return
const directory = base64Decode(params.dir)
const directory = params.dir ? base64Decode(params.dir) : undefined
const id = params.id

if (!directory || !id) return

setStore("lastSession", directory, id)
notification.session.markViewed(id)
untrack(() => layout.projects.expand(directory))
Expand Down Expand Up @@ -964,6 +967,35 @@ export default function Layout(props: ParentProps) {
<ProjectDragOverlay />
</DragOverlay>
</DragDropProvider>
<Show when={expanded() && globalSync.data.skill.length > 0}>
<div class="w-full flex flex-col min-h-0 shrink-0 px-2">
<button
class="flex items-center gap-2 py-2 text-12-medium text-text-weak uppercase tracking-wider hover:text-text-base transition-colors w-full text-left"
onClick={() => setStore("skillsExpanded", (e) => !e)}
>
<Icon
name="chevron-right"
size="small"
class="transition-transform duration-200"
classList={{ "rotate-90": store.skillsExpanded }}
/>
Skills
</button>
<Show when={store.skillsExpanded}>
<div class="flex flex-col gap-1 pb-2">
<For each={globalSync.data.skill}>
{(skill) => (
<Tooltip placement="right" value={skill.description}>
<div class="flex items-center px-2 py-1.5 rounded-lg hover:bg-surface-raised-base-hover cursor-default group/skill">
<span class="truncate text-14-medium text-text-strong">{skill.name}</span>
</div>
</Tooltip>
)}
</For>
</div>
</Show>
</div>
</Show>
</div>
</div>
<div class="flex flex-col gap-1.5 self-stretch items-start shrink-0 px-2 py-3">
Expand Down
7 changes: 6 additions & 1 deletion packages/opencode/src/cli/cmd/tui/context/sync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
[messageID: string]: Part[]
}
lsp: LspStatus[]
skill: { name: string; description: string; location: string }[]
mcp: {
[key: string]: McpStatus
}
Expand Down Expand Up @@ -86,6 +87,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
message: {},
part: {},
lsp: [],
skill: [],
mcp: {},
formatter: [],
vcs: undefined,
Expand Down Expand Up @@ -294,6 +296,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
...(args.continue ? [] : [sessionListPromise]),
sdk.client.command.list().then((x) => setStore("command", reconcile(x.data ?? []))),
sdk.client.lsp.status().then((x) => setStore("lsp", reconcile(x.data!))),
sdk.client.skill.list().then((x) => setStore("skill", reconcile(x.data ?? []))),
sdk.client.mcp.status().then((x) => setStore("mcp", reconcile(x.data!))),
sdk.client.formatter.status().then((x) => setStore("formatter", reconcile(x.data!))),
sdk.client.session.status().then((x) => {
Expand Down Expand Up @@ -348,11 +351,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
},
async sync(sessionID: string) {
if (fullSyncedSessions.has(sessionID)) return
const [session, messages, todo, diff] = await Promise.all([
const [session, messages, todo, diff, skills] = await Promise.all([
sdk.client.session.get({ sessionID }, { throwOnError: true }),
sdk.client.session.messages({ sessionID, limit: 100 }),
sdk.client.session.todo({ sessionID }),
sdk.client.session.diff({ sessionID }),
sdk.client.skill.list({ sessionID }),
])
setStore(
produce((draft) => {
Expand All @@ -365,6 +369,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
draft.part[message.info.id] = message.parts
}
draft.session_diff[sessionID] = diff.data ?? []
draft.skill = skills.data ?? []
}),
)
fullSyncedSessions.add(sessionID)
Expand Down
11 changes: 11 additions & 0 deletions packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ import {
Show,
Switch,
useContext,
batch,
} from "solid-js"
import { reconcile } from "solid-js/store"
import { Dynamic } from "solid-js/web"
import path from "path"
import { useRoute, useRouteData } from "@tui/context/route"
Expand Down Expand Up @@ -245,6 +247,15 @@ export function Session() {

const local = useLocal()

createEffect(() => {
const agent = local.agent.current()?.name
if (agent) {
sdk.client.skill.list({ sessionID: route.sessionID, agent }).then((x) => {
sync.set("skill", reconcile(x.data ?? []))
})
}
})

function moveChild(direction: number) {
if (children().length === 1) return
let next = children().findIndex((x) => x.id === session()?.id) + direction
Expand Down
26 changes: 26 additions & 0 deletions packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export function Sidebar(props: { sessionID: string }) {
mcp: true,
diff: true,
todo: true,
skill: true,
lsp: true,
})

Expand Down Expand Up @@ -157,6 +158,31 @@ export function Sidebar(props: { sessionID: string }) {
</Show>
</box>
</Show>
<Show when={sync.data.skill.length > 0}>
<box>
<box
flexDirection="row"
gap={1}
onMouseDown={() => sync.data.skill.length > 2 && setExpanded("skill", !expanded.skill)}
>
<Show when={sync.data.skill.length > 2}>
<text fg={theme.text}>{expanded.skill ? "▼" : "▶"}</text>
</Show>
<text fg={theme.text}>
<b>Skills</b>
</text>
</box>
<Show when={sync.data.skill.length <= 2 || expanded.skill}>
<For each={sync.data.skill}>
{(skill) => (
<text fg={theme.textMuted} wrapMode="char">
• {skill.name}
</text>
)}
</For>
</Show>
</box>
</Show>
<box>
<box
flexDirection="row"
Expand Down
Loading
Loading