Official
tabs
The tabbed detail pane beside the chat: opened files and diffs (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/tabs@0.2.0
Permissions in 0.2.0
Files
controller.ts4.8 KB
// Tabs' scoped effects serve the convergence/tabs service and consume legacy shared requests.
// The service emits which tab is visible; third-party plugins still open and
// close through the shared openTab/closeTab keys those writes once were.
// File reads run in the layer scope so closing/unloading never leaves a detached task.
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import { App, Host, Notify, parse } from "convergence/effect";
import type { AppState, ViewHandle } from "convergence";
import { closeRequest, closeTab, fileResponse, initialState, openRequest, openTab, readContent } from "./tabs.ts";
import type { Tab, TabInput } from "./tabs.ts";
// What the convergence/tabs events carry: which tab, or none visible.
export interface TabRef {
readonly kind: Tab["kind"];
readonly path: string;
}
const makeTabs = Effect.fn("Tabs.make")(function* () {
const host = yield* Host;
const app = yield* App;
const notify = yield* Notify;
const scope = yield* Effect.scope;
const state = initialState();
const views: ViewHandle[] = [];
const redraw = () => {
for (const view of views) view.update();
};
// The service handle is set after the views register; until a provider
// handle exists there is nobody to tell.
let emitActive: ((tab: TabRef | null) => void) | undefined;
const announce = (tab: Tab | undefined) => {
emitActive?.(tab ? { kind: tab.kind, path: tab.path } : null);
};
const readFile = Effect.fn("Tabs.readFile")(function* (tab: TabInput, id: string) {
yield* host.call("host/fs.read", { workspaceId: tab.workspaceId, path: tab.path }).pipe(
Effect.flatMap((result) => parse("file contents", fileResponse, result)),
Effect.tap((result) =>
Effect.sync(() => {
state.contents[id] = readContent(result);
}),
),
Effect.catchTag(["HostCallFailed", "NeedsReview", "PermissionNotGranted", "ParseFailed"], (error) =>
Effect.sync(() => {
state.contents[id] = {
error: error._tag === "ParseFailed" ? `${error.what}: ${error.issues.join("; ")}` : error.message,
};
}),
),
);
redraw();
});
const open = Effect.fn("Tabs.open")(function* (request: TabInput) {
const tab = openTab(state, request);
announce(tab);
redraw();
if (request.kind === "file") yield* Effect.forkIn(readFile(request, tab.id), scope, { startImmediately: true });
return tab;
});
const close = Effect.fn("Tabs.close")(function* (id: string) {
announce(closeTab(state, id));
redraw();
});
const select = Effect.fn("Tabs.select")(function* (tab: Tab) {
state.active = tab.id;
announce(tab);
redraw();
});
// The convergence/tabs service methods: open and close by reference, and
// which tab is visible. They share the open path above, without consuming
// a legacy request.
const closeByRef = Effect.fn("Tabs.closeByRef")(function* (ref: TabRef) {
const tab = state.tabs.find((tab) => tab.kind === ref.kind && tab.path === ref.path);
if (tab) yield* close(tab.id);
return null;
});
const activeRef = Effect.fn("Tabs.activeRef")(function* (): Effect.fn.Return<TabRef | null> {
const tab = state.tabs.find((tab) => tab.id === state.active) ?? state.tabs[0];
return tab ? { kind: tab.kind, path: tab.path } : null;
});
const followApp = Effect.fn("Tabs.followApp")(
function* (next: AppState) {
const request = next.shared?.openTab;
if (request) {
const parsed = yield* parse("openTab", openRequest, request);
if (parsed.nonce !== state.handled) {
state.handled = parsed.nonce;
yield* open(parsed);
// Consumed requests must not reopen closed tabs after the app restarts.
yield* app.update({ shared: { openTab: null } });
}
}
const gone = next.shared?.closeTab;
if (gone) {
const parsed = yield* parse("closeTab", closeRequest, gone);
if (parsed.nonce !== state.handledClose) {
state.handledClose = parsed.nonce;
yield* app.update({ shared: { closeTab: null } });
const tab = state.tabs.find((tab) => tab.kind === parsed.kind && tab.path === parsed.path);
if (tab) yield* close(tab.id);
}
}
},
// Invalid shared data is reported without ending the request subscription.
Effect.catchTag("ParseFailed", (error) => notify.error(`${error.what}: ${error.issues.join("; ")}`)),
);
return {
state,
views,
open,
close,
closeByRef,
activeRef,
select,
followApp,
emitActive: (emit: typeof emitActive) => {
emitActive = emit;
},
};
});
export class Tabs extends Context.Service<Tabs, Effect.Success<ReturnType<typeof makeTabs>>>()("tabs/Tabs") {
static readonly layer = Layer.effect(Tabs, makeTabs());
}Versions
| Version | Published | Plugin API | Size | Permissions | Status |
|---|---|---|---|---|---|
| 0.2.0latest | Oct 5, 2026 | >=2 <3 | 18.8 KB | 3 permissions | Listed |
No comments yet.