Skip to content
Draft
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
26 changes: 26 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ members = [
"crates/aionui-team-prompts",
"crates/aionui-team",
"crates/aionui-project",
"crates/aionui-sidebar",
"crates/aionui-cron",
"crates/aionui-assistant",
"crates/aionui-app",
Expand Down Expand Up @@ -55,6 +56,7 @@ aionui-channel = { path = "crates/aionui-channel" }
aionui-team-prompts = { path = "crates/aionui-team-prompts" }
aionui-team = { path = "crates/aionui-team" }
aionui-project = { path = "crates/aionui-project" }
aionui-sidebar = { path = "crates/aionui-sidebar" }
aionui-cron = { path = "crates/aionui-cron" }
aionui-assistant = { path = "crates/aionui-assistant" }
aionui-app = { path = "crates/aionui-app" }
Expand Down
5 changes: 5 additions & 0 deletions crates/aionui-api-types/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ mod remote_agent;
mod response;
mod runtime;
mod shell;
mod sidebar;
mod skill;
mod system;
mod team;
Expand Down Expand Up @@ -156,6 +157,10 @@ pub use shell::{
OpenExternalRequest, OpenFileRequest, OpenFolderWithRequest, ShowItemInFolderRequest, SpeechToTextConfig,
SpeechToTextProvider, SpeechToTextResult, SttStreamClientMessage, SttStreamServerMessage, ToolType,
};
pub use sidebar::{
MoveOrderRequest, OrderItemRefDto, RemoveProjectItem, RemoveProjectItemKind, RemoveProjectResult, SidebarGroup,
SidebarItem, SidebarItemsResponse, SidebarResponse, SidebarScope, SidebarTeamItem,
};
pub use skill::{
AddExternalPathRequest, DeleteSkillRequest, ExportSkillRequest, ExternalSkillSourceResponse,
ImportSkillFailureResponse, ImportSkillRequest, ImportSkillResponse, MaterializeSkillsRequest,
Expand Down
172 changes: 172 additions & 0 deletions crates/aionui-api-types/src/sidebar.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
//! Sidebar read-model DTOs (`GET /api/sidebar`, `GET /api/sidebar/items`).
//!
//! One request renders the whole left panel: the backend classifies every
//! conversation/team into its group (pinned / project / pseudo-dir / chats),
//! windows each group, and hydrates items. The frontend only renders in the
//! given order — it runs no classification. See
//! `feat-project-design/temp/left-panel/api-contract-sidebar.md` §4.

use serde::{Deserialize, Serialize};

use crate::conversation::ConversationResponse;

/// Root of `GET /api/sidebar`.
///
/// `groups` order **is** render order: `pinned → project-area (project + dir
/// interleaved) × N → chats`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SidebarResponse {
/// Rendered top to bottom in this exact order.
pub groups: Vec<SidebarGroup>,
/// True when the project area exceeded the 100-group hard cap and was
/// truncated.
pub has_more_groups: bool,
}

/// Response of `GET /api/sidebar/items` — one more window of a single group.
///
/// Same shape as [`SidebarGroup`] minus `scope` (the caller already knows which
/// group it paged). See `api-contract-sidebar.md` §3.2.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SidebarItemsResponse {
pub items: Vec<SidebarItem>,
pub has_more: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub next_cursor: Option<String>,
}

/// One group (a section's window) in the sidebar.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SidebarGroup {
pub scope: SidebarScope,
pub items: Vec<SidebarItem>,
/// True when this group has items beyond the returned window (paginate via
/// `GET /api/sidebar/items?scope=<token>&cursor=<next_cursor>`).
pub has_more: bool,
/// Keyset cursor for the next page; `None` iff `has_more` is false.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub next_cursor: Option<String>,
}

/// Which section a group belongs to; the tag doubles as the group-head shape.
///
/// The frontend has three sections (pinned / project / chats); both `Project`
/// and `Dir` render into the project area, distinguished only by group-head
/// form (a real project head carries the "+" / remove entry, a dir head does
/// not).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum SidebarScope {
/// Pinned group. Pinned rows appear only here.
Pinned,
/// Real project group. `workspace` feeds the project-head "+" entry
/// (resolved server-side from the project source, not scanned from items).
Project {
project_id: String,
name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
workspace: Option<String>,
},
/// Pseudo project group (directory aggregation). `key` is the dir token used
/// for paging / `win`; `name` is the directory's last segment.
Dir { key: String, path: String, name: String },
/// The flat "chats" group.
Chats,
}

/// One row in a group: either a full conversation or an aggregated team row.
// `Conversation` is by far the common variant and the hot path (most sidebar
// rows are conversations); boxing it just to shrink the rarer `Team` variant
// would add a heap allocation per row for no real memory win, so the size
// disparity is accepted deliberately.
#[allow(clippy::large_enum_variant)]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum SidebarItem {
/// A conversation row, reusing the shared conversation DTO. Its `pinned`
/// flag is derived from `user_order` row existence, not any table column.
Conversation { conversation: ConversationResponse },
/// An aggregated team row (server-side aggregate; the frontend does not
/// reconstruct it from member conversations).
Team(SidebarTeamItem),
}

/// Result of `DELETE /api/sidebar/project/{id}` (and its `dry_run` preview).
///
/// Counts are of the units classified into the project's group (BR-19). A live
/// delete reports how many were actually removed; a `dry_run` reports how many
/// *would* be — the two agree when no concurrent deletion races in. Team-member
/// conversations are not counted separately: they are folded into their team and
/// removed by the team cascade, so only the visible rows (`teams_deleted` +
/// `conversations_deleted`) are reported.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RemoveProjectResult {
/// Teams classified into the project's group.
pub teams_deleted: i64,
/// Independent conversations classified into the project's group.
pub conversations_deleted: i64,
/// The named units in the delete set, so a `dry_run` preview can list *which*
/// items go — not just how many. Pinned members live in the top pinned group
/// (B1 double-render: a project's pinned rows are anti-joined out of its own
/// group), so the frontend cannot reconstruct project membership itself; the
/// names must come from here. Empty on a live delete (the preview already
/// showed them).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub items: Vec<RemoveProjectItem>,
}

/// One named unit in a [`RemoveProjectResult`] preview.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RemoveProjectItem {
pub name: String,
/// Whether the unit is currently pinned (hoisted into the top pinned group).
pub pinned: bool,
pub kind: RemoveProjectItemKind,
}

/// Which sidebar unit a [`RemoveProjectItem`] is.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RemoveProjectItemKind {
Conversation,
Team,
}

/// A `(item_type, item_id)` reference in an ordering request body.
///
/// `item_type` is the raw TEXT enum value (`"conversation"` / `"team"`); the
/// service parses it and returns a 400 on an unknown value, mirroring the
/// pin/unpin path parameters.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OrderItemRefDto {
pub item_type: String,
pub item_id: String,
}

/// `POST /api/order/{scene}/move` body: drag-drop placement.
///
/// The frontend sends only anchors — never `order_key` numbers (BR-26). `after`
/// = `null` moves `moved` to the top of the scene; otherwise `moved` is placed
/// directly after `after`. The server computes the key.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MoveOrderRequest {
pub moved: OrderItemRefDto,
#[serde(default)]
pub after: Option<OrderItemRefDto>,
}

/// Aggregated team row for the sidebar.
///
/// Membership grouping is already expressed by the group the row sits in, so
/// (unlike the old draft) there is no `project` back-reference.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SidebarTeamItem {
pub team_id: String,
pub name: String,
/// `MAX(updated_at)` across active member conversations.
pub updated_at: i64,
/// Derived from a `user_order` scene=`pinned` row existing for this team.
pub pinned: bool,
/// Active member conversation ids, `created_at` ascending.
pub member_conversation_ids: Vec<String>,
}
1 change: 1 addition & 0 deletions crates/aionui-app/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ aionui-team.workspace = true
aionui-team-prompts.workspace = true
aionui-cron.workspace = true
aionui-project.workspace = true
aionui-sidebar.workspace = true
aionui-assistant.workspace = true
aionui-runtime.workspace = true
aionui-process.workspace = true
Expand Down
6 changes: 6 additions & 0 deletions crates/aionui-app/src/router/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ use aionui_office::{office_proxy_routes, office_routes};
use aionui_project::project_routes;
use aionui_realtime::{NoopMessageRouter, WsHandlerState, ws_upgrade_handler};
use aionui_shell::shell_routes;
use aionui_sidebar::sidebar_routes;
use aionui_system::{ClientPrefService, connection_test_routes, system_routes};
use aionui_team::{TeamSessionService, team_routes};

Expand Down Expand Up @@ -283,6 +284,10 @@ pub fn create_router_with_all_state(services: &AppServices, states: ModuleStates
let project_authenticated =
project_routes(states.project).route_layer(from_fn_with_state(auth_mw_state.clone(), auth_middleware));

// Sidebar read + ordering routes protected by auth middleware
let sidebar_authenticated =
sidebar_routes(states.sidebar).route_layer(from_fn_with_state(auth_mw_state.clone(), auth_middleware));

// MCP routes protected by auth middleware
let mcp_authenticated =
mcp_routes(states.mcp).route_layer(from_fn_with_state(auth_mw_state.clone(), auth_middleware));
Expand Down Expand Up @@ -364,6 +369,7 @@ pub fn create_router_with_all_state(services: &AppServices, states: ModuleStates
.merge(connection_test_authenticated)
.merge(file_authenticated)
.merge(project_authenticated)
.merge(sidebar_authenticated)
.merge(mcp_authenticated)
.merge(extension_authenticated)
.merge(hub_authenticated)
Expand Down
70 changes: 69 additions & 1 deletion crates/aionui-app/src/router/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,10 @@ use aionui_mcp::{
McpConfigService, McpConnectionTestService, McpRouterState, McpSyncService, OpencodeAdapter, QwenAdapter,
};
use aionui_office::{ConversionService, OfficeRouterState, OfficecliWatchManager, ProxyService};
use aionui_project::ProjectRouterState;
use aionui_project::{ProjectRouterState, ProjectService};
use aionui_realtime::{MessageRouter, TokenUserResolver, WsHandlerState};
use aionui_shell::ShellRouterState;
use aionui_sidebar::{SidebarRouterState, SidebarService};
use aionui_system::{
ClientPrefService, ConnectionTestRouterState, ConnectionTestService, FeedbackDiagnosticsService, ModelFetchService,
ProtocolDetectionService, ProviderService, RuntimePrepareService, SettingsService, SystemRouterState,
Expand Down Expand Up @@ -129,6 +130,7 @@ pub struct ModuleStates {
pub connection_test: ConnectionTestRouterState,
pub file: FileRouterState,
pub project: ProjectRouterState,
pub sidebar: SidebarRouterState,
pub mcp: McpRouterState,
pub extension: ExtensionRouterState,
pub hub: HubRouterState,
Expand Down Expand Up @@ -309,6 +311,7 @@ pub async fn build_module_states(
connection_test: build_module_state_phase(&boot, "connection_test", build_connection_test_state),
file: build_module_state_phase(&boot, "file", || build_file_state(services))?,
project: build_module_state_phase(&boot, "project", || build_project_state(services)),
sidebar: build_module_state_phase(&boot, "sidebar", || build_sidebar_state(services)),
mcp: build_module_state_phase(&boot, "mcp", || build_mcp_state(services)),
extension: ext_state,
hub: hub_state,
Expand All @@ -331,6 +334,17 @@ pub async fn build_module_states(
elapsed_ms = boot.elapsed().as_millis(),
"startup: module state build completed"
);
// Late-inject the sidebar's remove-project ports: the team service is built
// after the sidebar state above, so the wiring cannot happen inside
// `build_sidebar_state`. `set_remove_project_ports` is set-once.
states
.sidebar
.service
.set_remove_project_ports(Arc::new(RemoveProjectAdapter {
conversation: services.conversation_service.clone(),
team: states.team.service.clone(),
project: services.project_service.clone(),
}));
states
.conversation
.service
Expand Down Expand Up @@ -503,6 +517,58 @@ pub fn build_project_state(services: &AppServices) -> ProjectRouterState {
}
}

/// Build the sidebar read/ordering router state from application services.
/// The sidebar store and ordering store share the app-wide pool; `work_dir` is
/// the conversation temp-workspace root used for path classification (must match
/// `ProjectService`'s temp root).
pub fn build_sidebar_state(services: &AppServices) -> SidebarRouterState {
let sidebar_store: Arc<dyn aionui_db::ISidebarStore> =
Arc::new(aionui_db::SqliteSidebarStore::new(services.database.pool().clone()));
let service = SidebarService::new(
sidebar_store,
services.user_order_store.clone(),
services.work_dir.join("conversations"),
);
SidebarRouterState {
service: Arc::new(service),
}
}

/// Adapts the concrete conversation / team / project services to the sidebar's
/// [`aionui_sidebar::RemoveProjectPorts`] trait so `remove_project` (BR-19) can
/// reuse the existing per-unit delete paths (`ConversationService::delete`,
/// `TeamSessionService::remove_team`, `ProjectService::delete_project`) without
/// the sidebar crate depending on any of them.
struct RemoveProjectAdapter {
conversation: ConversationService,
team: Arc<TeamSessionService>,
project: ProjectService,
}

#[async_trait::async_trait]
impl aionui_sidebar::RemoveProjectPorts for RemoveProjectAdapter {
async fn delete_conversation(&self, user_id: &str, conversation_id: &str) -> Result<(), String> {
self.conversation
.delete(user_id, conversation_id)
.await
.map_err(|err| err.to_string())
}

async fn remove_team(&self, user_id: &str, team_id: &str) -> Result<(), String> {
self.team
.remove_team(user_id, team_id)
.await
.map_err(|err| err.to_string())
}

async fn delete_project_record(&self, user_id: &str, project_id: &str) -> Result<(), String> {
self.project
.delete_project(user_id, project_id)
.await
.map_err(|err| err.to_string())
}
}

/// Build the default `McpRouterState` from application services.
pub fn build_mcp_state(services: &AppServices) -> McpRouterState {
let pool = services.database.pool().clone();
Expand Down Expand Up @@ -762,6 +828,8 @@ pub fn build_team_state(
aionui_team::TeamPromptDumpConfig::from_data_dir(&services.data_dir, services.dump_prompts),
);
service.with_project_service(Arc::new(services.project_service.clone()));
// Path-2 cascade: removing a team drops its `user_order` row (sidebar §4.3).
service.with_user_order_store(services.user_order_store.clone());
TeamRouterState {
service,
active_leases: services.active_lease_registry.clone(),
Expand Down
Loading
Loading