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
2 changes: 1 addition & 1 deletion browser-host/sidebar-sort.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ function sectionChoice(coordinate, validIntent, edit, field) {
intent.group,
intent.mode,
now,
intent.activeWindowSeconds,
field === "filters" ? intent.activeWindowSeconds : undefined,
);
const projected = project(blob, intent.sectionIds);
if (Buffer.byteLength(JSON.stringify(blob)) > 128 * 1024)
Expand Down
49 changes: 49 additions & 0 deletions browser-host/sidebar-sort.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,55 @@ function harness() {
};
}
const intent = { group: "section:work", mode: "recent", sectionIds: ["work"] };
it("encrypts plugin choices without putting them in legacy sort fields and confirms reloads", async () => {
const h = harness();
const mode = "plugin:example.plugin/rows/reverse";
let head = h.encrypt({ version: 1, groups: { dms: "recent" } });
const publish = vi.fn(async (event) => {
head = event;
});
const choose = (selected) =>
mutateSidebarSort(
{ group: "channels", mode: selected, sectionIds: [] },
h.secret,
async () => [head],
publish,
);
expect(await choose(mode)).toEqual({ channels: mode, dms: "recent" });
expect(h.decode(head).groups).toEqual({ dms: "recent" });
expect(h.decode(head).meta.g.channels[2]).toBeNull();
expect(await choose(mode)).toEqual({ channels: mode, dms: "recent" });
expect(publish).toHaveBeenCalledOnce();
expect(await choose("alpha")).toEqual({ dms: "recent" });
expect(publish).toHaveBeenCalledTimes(2);
});
it("preserves another device's plugin choice when the writer has a stale section list", async () => {
const h = harness();
const mode = "plugin:example.plugin/rows/reverse";
let head = prepareSidebarSort(
[],
{ group: "section:new", mode, sectionIds: ["new"] },
h.secret,
100_000,
).event;
const saved = h.decode(head).pluginSort;
expect(
await mutateSidebarSort(
{ group: "channels", mode: "recent", sectionIds: [] },
h.secret,
async () => [head],
async (event) => {
head = event;
},
),
).toEqual({ channels: "recent" });
const current = h.decode(head);
expect(current.pluginSort).toEqual(saved);
expect(
projectSidebarPreferences(undefined, undefined, undefined, current, ["new"])
.sort,
).toEqual({ channels: "recent", "section:new": mode });
});
it("mutates only the selected register, imports recognized legacy modes, and encodes alpha as a tombstone", () => {
const h = harness();
const blob = {
Expand Down
78 changes: 78 additions & 0 deletions docs/plugin-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,84 @@ ctx.reflect.get("sidebarSections")?.register({
});
```

### Decorating host sidebar rows and adding sort modes

`ctx.reflect.get("sidebarRows")` is an optional version-1 capability for host
Team channel and direct-message rows, including rows moved into custom groups.
Solo conversations are not supported yet. Use the reflective lookup rather than
required `inject` so the plugin still activates on older hosts.

```tsx
ctx.reflect.get("sidebarRows")?.register({
version: 1,
id: "row-details",
render(row, Default) {
return row.id === "chosen-channel-id"
? <div title="Tracked by this plugin">{Default}</div>
: Default;
},
});
```

`row` contains `id`, `kind` (`team` or `dm`), `label`, `sectionKey`, the current
`channel` summary (including available labels, visibility and activity data), and
its current `unread` snapshot. Unknown unread counts remain `null`; activity
fields retain their existing units and unknown values. This is the host's
currently authorized roster, not a channel discovery API.

`Default` is a React element, not a component constructor: return it, wrap it,
or return a replacement element. Returning `null` intentionally hides the row.
Render callbacks must be pure; return a component element if it needs hooks.
The live host element keeps selection, unread badges, keyboard focus, menus and
channel dragging without copying their implementation. Replacements and wrappers
that discard or interfere with that element own the interactions they remove.
Keep the wrapper shape stable across live row updates; moving `Default` to a
different tree position remounts it and can discard focus or open menus.

Active contributions compose in lexical plugin-id/contribution-id order, with
later callbacks receiving the preceding result (possibly `null`). Callback
throws, invalid return values, and descendant render failures restore the
original host element for that row only, not a partially decorated result.
Callback and invalid-result fallbacks retain the host row across live updates.
Descendant render failures use a React error boundary and can remount that row,
resetting its focus or open menu when retried after a live update.
Disabling, replacing or disposing a plugin removes its contributions through its
Cordis scope. With no active render contribution, the existing host row renders
without an extra DOM wrapper.

```ts
ctx.reflect.get("sidebarRows")?.register({
version: 1,
id: "activity-order",
sortModes: [{
id: "oldest",
label: "Oldest activity first",
compare: (a, b) =>
(a.channel.lastActivityAt ?? 0) -
(b.channel.lastActivityAt ?? 0) || a.id.localeCompare(b.id),
}],
});
```

Modes appear beside A–Z and Recent in each Team section's existing Sort menu.
Their persisted values are `plugin:<plugin-id>/<contribution-id>/<mode-id>`;
ids use lowercase ASCII letters, digits, dots, underscores and hyphens, starting
with a letter or digit, with a combined value limit of 512 characters. A registration
with invalid fields or incompatible sort ids is rejected as a whole, including
its renderer; register render-only contributions separately if needed. Sorting
only rearranges existing rows. Comparators must be pure and return finite
numbers; ties preserve host order. A throw or non-finite result restores the
section's host order. Live unread and channel updates recompute custom sorting.
Selecting a plugin mode loads the same channel activity history as Recent,
sharing its request lifetime and cancellation. Activity fields remain unknown
until that read completes; comparators must handle unknown values.
If a saved mode is unavailable, the section uses A–Z while retaining the saved
choice for re-enabling the plugin. Plugin choices use a separate extension in
the existing sort record, leaving built-in fields readable by older hosts.
A later built-in sort edit to that section supersedes its plugin choice;
edits to another section leave it intact. There is no automatic reorder-without-menu
hook and no insertion hook in this version; use `sidebarSections` for new rows.

A plugin exports `inject` and `apply(ctx)`. Pages register with
`ctx.pages.register({ id, title, layout?, companion?, primary?, placement?, icon?, component, sidebar? })`. Panels register with
`ctx.panels.register({ id, title, matches, order?, launcher?, toolbar?, component })`. An optional `toolbar` supplies live controls beside shell search in place of the static launcher. Its lifetime stays with its plugin, and shell render failures are isolated. Huddles is the first caller. IDs are local to the
Expand Down
52 changes: 44 additions & 8 deletions src-tauri/src/identity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -549,7 +549,7 @@ fn validate_sidebar_payload(coordinate: &str, value: &serde_json::Value) -> Resu
coordinate,
"channel-sections" | "channel-sort" | "channel-filter"
) {
validate_sidebar_meta(coordinate, meta)?;
validate_sidebar_meta(coordinate, meta, false)?;
let projection = project_sidebar_meta(coordinate, meta);
let projected = projection.as_object().ok_or_else(invalid)?;
if projected
Expand All @@ -560,12 +560,28 @@ fn validate_sidebar_payload(coordinate: &str, value: &serde_json::Value) -> Resu
}
}
}
if coordinate == "channel-sort" {
if let Some(choices) = data.get("pluginSort") {
if !choices
.get("g")
.and_then(|v| v.as_object())
.is_some_and(|g| g.len() <= 104)
{
return Err(invalid());
}
validate_sidebar_meta(coordinate, choices, true)?;
}
}
Ok(())
}

// The signer admits only the bounded Desktop register schema, not arbitrary
// renderer-controlled trees. Retained tombstones share the plaintext byte budget.
fn validate_sidebar_meta(coordinate: &str, value: &serde_json::Value) -> Result<()> {
fn validate_sidebar_meta(
coordinate: &str,
value: &serde_json::Value,
plugin_sort: bool,
) -> Result<()> {
use serde_json::Value;
const MAX_SAFE: u64 = 9_007_199_254_740_991;
fn text(value: &Value, max: usize) -> bool {
Expand Down Expand Up @@ -612,12 +628,16 @@ fn validate_sidebar_meta(coordinate: &str, value: &serde_json::Value) -> Result<
let valid = if matches!(coordinate, "channel-sort" | "channel-filter") {
map(meta.get("g"), 264, |v| {
reg(v, |v| {
v.is_null()
|| if coordinate == "channel-filter" {
matches!(v.as_str(), Some("unreads" | "active"))
} else {
matches!(v.as_str(), Some("alpha" | "recent"))
}
if plugin_sort {
v.as_str().is_some_and(valid_plugin_sidebar_sort_mode)
} else {
v.is_null()
|| if coordinate == "channel-filter" {
matches!(v.as_str(), Some("unreads" | "active"))
} else {
matches!(v.as_str(), Some("alpha" | "recent"))
}
}
})
})
} else {
Expand All @@ -643,6 +663,22 @@ fn validate_sidebar_meta(coordinate: &str, value: &serde_json::Value) -> Result<
Ok(())
}

fn valid_plugin_sidebar_sort_mode(value: &str) -> bool {
let Some(value) = value.strip_prefix("plugin:").filter(|_| value.len() <= 512) else {
return false;
};
let parts: Vec<_> = value.split('/').collect();
parts.len() == 3
&& parts.iter().all(|part| {
part.as_bytes()
.first()
.is_some_and(u8::is_ascii_alphanumeric)
&& part
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b"._-".contains(&b))
})
}

// Called only after shape validation. Requiring these exact fields also binds
// metadata's live counts to the existing projection limits above.
fn project_sidebar_meta(coordinate: &str, meta: &serde_json::Value) -> serde_json::Value {
Expand Down
34 changes: 34 additions & 0 deletions src-tauri/src/identity/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -761,3 +761,37 @@ async fn main_agent_signer_admits_only_one_agent() {
.is_err());
}
}

#[test]
fn sidebar_plugin_sort_registers_are_bounded_and_namespaced() {
use serde_json::json;
for mode in [
"plugin:example.plugin/rows/reverse",
&format!("plugin:a/b/{}", "x".repeat(501)),
] {
let record = json!({"version":1,"groups":{"dms":"recent"},"meta":{"v":1,"g":{"channels":[100,"1234567890abcdef",null],"dms":[99,"1234567890abcdef","recent"]}},"pluginSort":{"v":1,"g":{"channels":[100,"1234567890abcdef",mode]}}});
assert!(super::validate_sidebar_payload("channel-sort", &record).is_ok());
}
for mode in [
"plugin:x",
"alpha",
"recent",
"plugin:a/b/",
"plugin:a/b/c/d",
"plugin:A/b/c",
&format!("plugin:a/b/{}", "x".repeat(502)),
] {
let record = json!({"version":1,"groups":{},"meta":{"v":1,"g":{"channels":[100,"1234567890abcdef",null]}},"pluginSort":{"v":1,"g":{"channels":[100,"1234567890abcdef",mode]}}});
assert!(super::validate_sidebar_payload("channel-sort", &record).is_err());
}
for choices in [
json!({"v":2,"g":{}}),
json!({"v":1}),
json!({"v":1,"g":{"channels":[100,"bad-device","plugin:a/b/c"]}}),
json!({"v":1,"g":{"channels":[-1,"1234567890abcdef","plugin:a/b/c"]}}),
json!({"v":1,"g":{"channels":[100,"1234567890abcdef",null]}}),
] {
let record = json!({"version":1,"groups":{},"pluginSort":choices});
assert!(super::validate_sidebar_payload("channel-sort", &record).is_err());
}
}
1 change: 1 addition & 0 deletions src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,7 @@ function ConnectedApp({ services }: { services: AppServices }) {
chatIcons={services.conversation.chatIcons}
chatMenus={services.conversation.chatMenus}
sidebarSections={services.sidebarSections}
sidebarRows={services.sidebarRows}
target={workspace.target}
>
{pageNavigation}
Expand Down
3 changes: 3 additions & 0 deletions src/app/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { Context } from "@deepseek-ai/cordis";
import { BrowserService } from "../features/browser/service";
import { PagesService } from "../features/pages/service";
import { SoloNavigationService } from "../features/channel-navigation/solo-navigation";
import { SidebarRowsService } from "../features/channel-navigation/sidebar-rows-service";
import { SidebarSectionsService } from "../features/channel-navigation/sidebar-sections-service";
import { bundledPlugins } from "../bundled";
import { conversationFeatures } from "./conversation-features";
Expand All @@ -62,6 +63,7 @@ export function createServices() {
const decisions = new DecisionsService(ctx);
const pages = new PagesService(ctx);
const sidebarSections = new SidebarSectionsService(ctx);
const sidebarRows = new SidebarRowsService(ctx);
const panels = new PanelsService(ctx);
const accountActions = new AccountActionsService(ctx);
const conversation = new ConversationService(ctx);
Expand Down Expand Up @@ -175,6 +177,7 @@ export function createServices() {
adminAccess,
pages,
sidebarSections,
sidebarRows,
panels,
plugins,
relay,
Expand Down
3 changes: 3 additions & 0 deletions src/app/shell/PrimarySidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { readView, writeView } from "../../shared/view-state";
import type { ChannelList } from "../../features/relay/contracts";
import type { ContributionReader } from "../../features/conversation/contracts";
import type { ChatIcon, ChatMenu } from "../../features/conversation/service";
import type { SidebarRows } from "../../features/channel-navigation/sidebar-rows-service";
import type { SidebarSections } from "../../features/channel-navigation/sidebar-sections-service";

const noSubscription = () => () => {};
Expand All @@ -49,6 +50,7 @@ type Props = {
chatIcons?: ContributionReader<ChatIcon> | undefined;
chatMenus?: ContributionReader<ChatMenu> | undefined;
sidebarSections?: SidebarSections | undefined;
sidebarRows?: SidebarRows | undefined;
target: OpenTarget;
children: ReactNode;
};
Expand Down Expand Up @@ -210,6 +212,7 @@ function ScopedSidebar({
scope={scope}
viewer={connection.viewer}
sidebarSections={props.sidebarSections}
sidebarRows={props.sidebarRows}
cached={!!connection.cached}
layoutReady={filter === "team" || soloReady}
connectionError={connection.error}
Expand Down
Loading
Loading