Official

sidebar

Built-in sidebar UI (JavaScript UI plugin).

The app opens the listing; nothing installs until an agent in your Plugins workspace has read the files and you enable the plugin. In a terminal: cvg install convergence/sidebar@0.2.0

Permissions in 0.2.0

  • Control chats chats.controlHighCreates chats, sends prompts and cancels runs.Add and remove workspaces, and create, rename, check off and archive chats
  • Read chats chats.readMediumReads your transcripts and chat lists.List workspaces and chats, and follow them as agents work
  • Show panels ui.slotsLowShows views in the listed parts of the window.Draw the sidebarSlots: left

Files

controller.ts15.4 KB
// Sidebar actions: the refresh that loads workspaces and chats, the chat
// writes (new, rename, archive), and the lazy transcript hydration the rows
// trigger. One layer owns the state.
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Stream from "effect/Stream";
import * as z from "zod";
import { App, Host, Kernel, Notify, Plugin, Services, hostError, parse, promise } from "convergence/effect";
import type { EffectClient, HostError } from "convergence/effect";
import type { Chat as ChatService } from "../chat/contract.ts";
import type { Chat, HostEvents, PresetColor, ViewHandle, Workspace } from "convergence";
import type { SectionId } from "./sections.ts";
import {
  COLLAPSED_KEY,
  SECTIONS,
  applyAgentEvent,
  applyChatChange,
  applyChatRemoved,
  applyTranscriptReset,
  initialState,
  listedChats,
  recentAgent,
} from "./sections.ts";
import { createPreview, transcriptPreview } from "./preview.ts";

const describe = (error: unknown): string =>
  error instanceof Error && error.message ? error.message : "unknown error";

const makeSidebar = Effect.fn("Sidebar.make")(function* () {
  const host = yield* Host;
  const kernel = yield* Kernel;
  const app = yield* App;
  const notify = yield* Notify;
  const plugin = yield* Plugin;
  const services = yield* Services;
  const scope = yield* Effect.scope;
  const state = initialState();
  const views: ViewHandle[] = [];
  const redraw = (): void => {
    for (const view of views) view.update();
  };

  // Storage may hold values the JavaScript original wrote without a schema;
  // a bad shape keeps the default rather than failing the refresh.
  const stored = <S extends z.ZodType>(key: string, schema: S): Effect.Effect<z.infer<S> | undefined, HostError> =>
    host.call("host/storage.get", { key }).pipe(
      Effect.flatMap((result) => parse(key, schema, result.value)),
      Effect.catchTag("ParseFailed", () => Effect.succeed(undefined)),
    );

  // The agents list leaves synchronously, like the baseline's fire-and-forget
  // `api.host.agents.list({}).then(...)` in refresh(): the call must be
  // recorded before the first render's transcript reads. A forked
  // `host.call` would leave after those reads, flipping the host-call order.
  const loadAgentsFrom = Effect.fn("Sidebar.loadAgents")(function* (pending: Promise<unknown>) {
    const result = yield* Effect.tryPromise({
      try: () => pending as Promise<{ agents: typeof state.agents }>,
      catch: (error) => hostError("host/agents.list", error),
    });
    state.agents = [...result.agents];
    const defaultAgent = yield* stored("defaultAgent", z.string());
    if (defaultAgent && state.agents.some((a) => a.id === defaultAgent)) state.lastAgent = defaultAgent;
    const first = state.agents[0];
    if (!state.lastAgent && first) state.lastAgent = first.id;
    redraw();
  });

  // The folded sections come back from storage after the first draw.
  const loadCollapsed = Effect.fn("Sidebar.loadCollapsed")(function* () {
    const saved = yield* stored(COLLAPSED_KEY, z.array(z.string()));
    for (const section of SECTIONS) if (saved?.includes(section.id)) state.collapsed[section.id] = true;
    redraw();
  });

  const refresh = Effect.fn("Sidebar.refresh")(function* () {
    const [workspaces, chats] = yield* loadLists();
    state.workspaces = [...workspaces.workspaces];
    state.colors = workspaces.colors ?? [];
    // Run events can advance a chat while the list request is in flight.
    const live = new Map(state.chats.map((chat) => [chat.id, chat]));
    state.chats = chats.chats.map((chat) => {
      const current = live.get(chat.id);
      return current && current.updatedAt > chat.updatedAt ? current : chat;
    });
    state.filter = state.filter.filter((id) => state.workspaces.some((w) => w.id === id));
    if (!state.loaded) yield* Effect.forkIn(loadCollapsed(), scope);
    state.loaded = true;
    const pendingAgents = yield* Effect.sync(() => plugin.api.host.agents.list({}));
    yield* Effect.forkIn(loadAgentsFrom(pendingAgents), scope);
    const current = yield* app.state;
    const firstWorkspace = state.workspaces[0];
    if (!state.workspaces.some((w) => w.id === current.workspaceId) && firstWorkspace) {
      yield* app.update({ workspaceId: firstWorkspace.id });
    }
    redraw();
  });

  // --- chats -------------------------------------------------------------------

  // The list calls leave synchronously, in one block: the registration
  // flush that follows activate must already see them. Failures map to
  // HostError exactly as Host.call would.
  const loadLists = Effect.fn("Sidebar.loadLists")(function* () {
    const pending = yield* Effect.sync(
      () => [plugin.api.host.workspaces.list({}), plugin.api.host.chats.list({})] as const,
    );
    const adopt = <A>(method: string, task: Promise<A>): Effect.Effect<A, HostError> =>
      Effect.tryPromise({ try: () => task, catch: (error) => hostError(method, error) });
    return yield* Effect.all([adopt("host/workspaces.list", pending[0]), adopt("host/chats.list", pending[1])], {
      concurrency: "unbounded",
    });
  });

  const defaultWorkspace = Effect.fn("Sidebar.defaultWorkspace")(function* () {
    const current = yield* app.state;
    return (state.filter.length === 1 ? state.filter[0] : undefined) ?? current.workspaceId ?? state.workspaces[0]?.id ?? null;
  });

  const pickAgent = Effect.fn("Sidebar.pickAgent")(function* (workspaceId: string) {
    const first = state.agents[0];
    const agent = recentAgent(state, workspaceId) ?? state.lastAgent ?? first?.id;
    if (!agent) {
      yield* notify.warning("Enable an agent in Settings first.");
      return null;
    }
    yield* host.call("host/storage.set", { key: "defaultAgent", value: agent }).pipe(Effect.ignore);
    return agent;
  });

  const newChat = Effect.fn("Sidebar.newChat")(function* (workspaceId: string) {
    const agent = yield* pickAgent(workspaceId);
    if (!agent) return;
    // A draft with typed text or a place in the queue is in use: New chat
    // from it opens another draft rather than the same one.
    const inUse = [...Object.keys(state.draftTitles), ...state.queued];
    const chat = yield* host.call("host/chats.create", { workspaceId, agentId: agent, inUse });
    yield* app.select(chat.id, workspaceId);
    yield* refresh();
  });

  const addWorkspace = Effect.fn("Sidebar.addWorkspace")(function* () {
    const picked = yield* promise("pickFolder", () => plugin.api.pickFolder());
    if (!picked?.path) return;
    const workspace = yield* host.call("host/workspaces.add", { path: picked.path });
    // The list shows the new folder alone, and the center opens a draft
    // there: the chat a user adds a folder for.
    state.filter = [workspace.id];
    yield* newChat(workspace.id);
  });

  const removeWorkspace = Effect.fn("Sidebar.removeWorkspace")(function* (workspace: Workspace) {
    // The workspace-changed event that follows triggers the refresh and redraw.
    yield* host.call("host/workspaces.remove", { workspaceId: workspace.id });
    state.filter = state.filter.filter((id) => id !== workspace.id);
  });

  // A workspace's mark: a folder in `color`, or (`null`) its project icon.
  // The workspace-changed event that follows redraws.
  const setColor = Effect.fn("Sidebar.setColor")(function* (workspaceId: string, color: PresetColor | null) {
    yield* host
      .call("host/workspaces.set_color", { workspaceId, color })
      .pipe(Effect.catch((error) => notify.error(`Could not change the workspace's icon: ${describe(error)}`)));
  });

  const setFilter = Effect.fn("Sidebar.setFilter")(function* (workspaceIds: readonly string[]) {
    state.filter = [...workspaceIds];
    state.showAll = {};
    const [only] = workspaceIds;
    if (only && workspaceIds.length === 1) yield* app.update({ workspaceId: only });
    redraw();
  });

  // Folding a section hides its rows under the heading; the folded set is
  // kept so it survives a restart.
  const toggleSection = Effect.fn("Sidebar.toggleSection")(function* (id: SectionId) {
    if (state.collapsed[id]) delete state.collapsed[id];
    else state.collapsed[id] = true;
    redraw();
    yield* host
      .call("host/storage.set", { key: COLLAPSED_KEY, value: SECTIONS.filter((s) => state.collapsed[s.id]).map((s) => s.id) })
      .pipe(Effect.catch((error: unknown) => notify.error(`Could not save the folded sections: ${describe(error)}`)));
  });

  // --- renaming and archiving --------------------------------------------------

  const commitRename = Effect.fn("Sidebar.commitRename")(function* () {
    const editing = state.editing;
    if (!editing) return;
    state.editing = null;
    redraw();
    yield* host
      .call("host/chats.update", { chatId: editing.id, title: editing.title.trim() || "New Chat" })
      .pipe(Effect.catch((error: unknown) => notify.error(`Rename failed: ${describe(error)}`)));
  });

  // Checking off takes at once: the chat is complete here from the click
  // and moves to Complete, and the host is told.
  const toggleCompleted = Effect.fn("Sidebar.toggleCompleted")(function* (chat: Chat) {
    const completed = !chat.completedAt;
    const index = state.chats.findIndex((c) => c.id === chat.id);
    const next: Chat = { ...chat, completedAt: completed ? new Date().toISOString() : undefined };
    if (index >= 0) state.chats[index] = next;
    redraw();
    yield* host
      .call("host/chats.update", { chatId: chat.id, completed })
      .pipe(Effect.catch((error: unknown) => notify.error(`Could not update the chat: ${describe(error)}`)));
  });

  // An archived chat leaves the list; its transcript stays with the host.
  const archive = Effect.fn("Sidebar.archive")(function* (chat: Chat) {
    yield* host
      .call("host/chats.update", { chatId: chat.id, archived: true })
      .pipe(Effect.catch((error: unknown) => notify.error(`Could not archive the chat: ${describe(error)}`)));
  });

  // Latest model activity for a chat's live line; the transcript is read
  // once, and only for chats a row actually showed.
  const ensurePreview = Effect.fn("Sidebar.ensurePreview")(function* (chatId: string) {
    const preview = (state.previews[chatId] ??= createPreview());
    if (preview.loading || preview.revision) return;
    preview.loading = true;
    yield* host.call("host/chats.transcript", { chatId }).pipe(
      Effect.tap((result) =>
        Effect.sync(() => {
          // Streaming activity or a reset since this read takes precedence.
          if (state.previews[chatId] === preview && !preview.revision) {
            Object.assign(preview, transcriptPreview(result.items), { loading: true });
            redraw();
          }
        }),
      ),
      Effect.catch((error: unknown) => notify.error(`Chat preview unavailable: ${describe(error)}`)),
    );
  });

  // --- events and commands -----------------------------------------------------

  // Queued chats and draft titles come from the chat plugin while it runs:
  // read once, then followed through its events. One episode per provider:
  // the previous follow ends when the provider goes.
  const followChat = Effect.fn("Sidebar.followChat")(function* () {
    yield* services.when<typeof ChatService>("convergence/chat").pipe(
      Stream.runForEach((option) =>
        Effect.gen(function* () {
          if (state.follow) {
            yield* Fiber.interrupt(state.follow);
            state.follow = undefined;
          }
          if (Option.isNone(option)) {
            state.chat = undefined;
            return;
          }
          const client: EffectClient<typeof ChatService> = option.value;
          state.chat = client;
          state.follow = yield* Effect.forkIn(
            Effect.all(
              [
                client.on("queuedChanged").pipe(
                  Stream.runForEach((queued) =>
                    Effect.sync(() => {
                      state.queued = new Set(queued);
                      redraw();
                    }),
                  ),
                ),
                client.on("draftTitlesChanged").pipe(
                  Stream.runForEach((drafts) =>
                    Effect.sync(() => {
                      state.draftTitles = { ...drafts };
                      redraw();
                    }),
                  ),
                ),
              ],
              { concurrency: "unbounded", discard: true },
            ),
            scope,
            { startImmediately: true },
          );
          // A provider that goes away mid-read leaves the last titles.
          yield* Effect.all(
            [
              client.queued({}).pipe(
                Effect.tap((queued) =>
                  Effect.sync(() => {
                    state.queued = new Set(queued);
                  }),
                ),
                Effect.ignore,
              ),
              client.draftTitles({}).pipe(
                Effect.tap((drafts) =>
                  Effect.sync(() => {
                    state.draftTitles = { ...drafts };
                  }),
                ),
                Effect.ignore,
              ),
            ],
            { concurrency: "unbounded", discard: true },
          );
          redraw();
        }),
      ),
      Effect.forkIn(scope, { startImmediately: true }),
    );
  });
  yield* followChat();

  const chatChanged = Effect.fn("Sidebar.chatChanged")(function* (event: HostEvents["chat_changed"]) {
    applyChatChange(state, event.chat);
    redraw();
  });
  const chatRemoved = Effect.fn("Sidebar.chatRemoved")(function* (event: HostEvents["chat_removed"]) {
    applyChatRemoved(state, event.chatId);
    redraw();
  });
  const transcriptReset = Effect.fn("Sidebar.transcriptReset")(function* (event: HostEvents["transcript_reset"]) {
    applyTranscriptReset(state, event.chatId);
    redraw();
  });
  const agentEvent = Effect.fn("Sidebar.agentEvent")(function* (event: HostEvents["agent"]) {
    if (applyAgentEvent(state, event)) redraw();
  });

  const newChatCommand = Effect.fn("Sidebar.newChatCommand")(function* () {
    const workspaceId = yield* defaultWorkspace();
    if (workspaceId) yield* newChat(workspaceId);
  });

  const toggleCompletedCommand = Effect.fn("Sidebar.toggleCompletedCommand")(function* () {
    const current = yield* app.state;
    const chat = state.chats.find((c) => c.id === current.chatId);
    if (chat) yield* toggleCompleted(chat);
  });

  // Cmd+1 to Cmd+9: the chat in that row of the list, top to bottom.
  const selectListedCommand = (index: number): Effect.Effect<void> =>
    Effect.suspend(() => {
      const chat = listedChats(state)[index];
      return chat ? app.select(chat.id, chat.workspaceId) : Effect.void;
    });

  return {
    state,
    views,
    refresh,
    runCommand: (id: string) => kernel.call("run_command", { id }).pipe(Effect.asVoid),
    select: (chatId: string, workspaceId: string): Effect.Effect<void> => app.select(chatId, workspaceId),
    route: (name: string): Effect.Effect<void> => app.route(name),
    newChat,
    addWorkspace,
    removeWorkspace,
    setColor,
    setFilter,
    toggleSection,
    commitRename,
    archive,
    toggleCompleted,
    toggleCompletedCommand,
    ensurePreview,
    chatChanged,
    chatRemoved,
    transcriptReset,
    agentEvent,
    newChatCommand,
    selectListedCommand,
  };
});

export class Sidebar extends Context.Service<Sidebar, Effect.Success<ReturnType<typeof makeSidebar>>>()(
  "sidebar/Sidebar",
) {
  static readonly layer = Layer.effect(Sidebar, makeSidebar());
}

Versions

VersionPublishedPlugin APISizePermissionsStatus
0.2.0latestOct 5, 2026>=2 <320.2 KB3 permissionsListed

Reviews and comments

0 threads · 0 reviews

No comments yet.