diff --git a/.archive/README.md b/.archive/README.md new file mode 100644 index 000000000..a1d629c82 --- /dev/null +++ b/.archive/README.md @@ -0,0 +1,55 @@ +# .archive + +Code parked out of the live build but kept in-tree (and in git history). Excluded from `tsconfig.json` (`exclude: [".archive"]`), so nothing here is type-checked or bundled. Paths mirror their original `src/` location, so restoring is a reverse `git mv`. + +## Browser "design tokens" tab (`token-category`) — archived 2026-07-14 + +The My Station browser primary sidebar was reduced to a **Sessions-only** variant (History and Design pills removed, pill header hidden — see `BrowserPrimarySidebar`'s `sessionsOnly` prop). The **Design** pill was the _only_ entry point for the "color / design tokens" viewer (`onOpenColorTokens` → `createColorTokensTab` → a `token-category` tab rendered by `TokenManagerPanel`). With that entry point gone, the whole `token-category` tab type became unreachable, so it was archived. + +**What moved here (self-contained to the feature):** + +- `src/modules/WorkStation/Browser/Panels/BrowserMainPane/content/TokenManagerContent/` — the token viewer panel (`TokenManagerPanel`) +- `src/modules/WorkStation/Browser/Panels/BrowserMainPane/components/DesignFileBar/` — used only by `TokenManagerContent` +- `src/modules/WorkStation/TabContent/renderers/tokenCategory.tsx` — the unified `token-category` renderer wrapper + +**What deliberately stayed live (shared with other features):** + +- `src/modules/WorkStation/Browser/hooks/useGlobalTokens.ts` — still used by the Browser sidebar's Design tab (`DesignTabGlobalTokens`), which remains in the **full** sidebar variant used by SessionReplay's "My Tabs" sidebar +- `src/modules/WorkStation/Browser/Panels/BrowserPrimarySidebar/tabs/{HistoryTab.tsx,DesignTab/}` — still rendered by the full (non-`sessionsOnly`) sidebar variant +- `src/modules/WorkStation/Browser/Panels/BrowserMainPane/{content/WebViewportContent,components/WebUrlBar}` — the live browser viewport + URL bar + +**Shared files edited in place** to sever the `token-category` branch: + +- `src/store/workstation/tabs/types.ts` — dropped `"token-category"` from the `WorkStationTabType` union and the `TOOL_TAB_TYPES` list +- `src/store/workstation/tabHost.ts`, `src/store/workstation/tabs/tabFactory.ts` — removed its `→ "browser"` host mapping +- `src/modules/WorkStation/TabContent/registry.ts`, `.../renderers/index.ts` — removed the renderer entry + barrel re-export +- `src/store/workstation/browser/tabs/index.ts` — removed the `token-category` id-helpers, `createTokenCategoryTab` / `createColorTokensTab`, the `isShowingTokenCategoryAtom` / `tokenCategoryTabsAtom` atoms, `TokenCategoryData`, and its `BROWSER_TAB_TYPES` membership +- `src/modules/WorkStation/Browser/BrowserLayout/{index.tsx,useBrowserLayoutState.ts}` — removed the `TokenManagerPanel` mount, `handleOpenColorTokens` / `handleOpenHistoryUrl`, and the `useGlobalTokens` auto-scan wiring + +**To restore:** reverse the `git mv`s above and revert the in-place edits (see the archival commit). + +**Note:** any browser tab of type `token-category` persisted in a user's saved workstation layout will no longer resolve to a renderer. This feature was reachable only via the removed Design pill, so that is expected. + +## WorkStation Database app — archived 2026-07-14 + +The WorkStation "Database" app (the **Data** dock app, its tab types, renderers, and the `DatabaseManager` module) was removed from the live WorkStation. See `docs/workstation-unification/phase-2-host-hoist-plan.md` for the broader unification effort this is part of. + +**What moved here (self-contained to the app):** + +- `src/modules/WorkStation/DatabaseManager/` — the whole host module +- `src/hooks/database/` — its hooks (`useSqliteDatabase`, `usePendingChanges`, `useQueryHistory`, `useDatabaseConnections`) +- `src/store/workstation/tabs/factories/database.ts` — db tab factories/creators +- `src/modules/WorkStation/TabContent/renderers/{table,query,schema,addConnection}.tsx` — the (placeholder) unified renderers +- `src/modules/WorkStation/shared/StatusBar/DatabaseStatusBar.tsx` + +**What deliberately stayed live (shared with other features):** + +- `src/engines/DatabaseCore/` and `src/store/workstation/database/` — used by MainApp → Integrations → Databases, the CodeMirror SQL editor, and the Code Editor's SQLite file preview +- `src/assets/databaseIcons/`, `src/hooks/workStation/database/` (Code Editor `.sqlite` preview) +- Rust: `src-tauri/crates/db-browser` and `crates/db-clients` (the `db_*` / `db_sql_*` Tauri commands) — still invoked by the above. `crates/database` is the app's own persistence and is unrelated. + +**Shared files that were edited in place (not moved)** to sever the db branch: AppShell (`AppShellContent`, `index.tsx`, `useAppShellDerivedState`, `useMyStationDockSegments`), tab store (`tabHost`, `tabs/types`, `tabFactory`, `factories/index`, `tabs/index`), `dockFilter/atoms`, `TabContent/registry`, routes (`routeViewModeConfig`, `routeGroups`, router redirect, `componentMapping`), and `StatusBarRenderer` + `shared/StatusBar/index`. + +**To restore:** reverse the `git mv`s above, revert the in-place edits (see the archival commit), and remove `.archive` from `tsconfig.json`'s `exclude`. + +**Known harmless leftovers (intentional, to limit ripple):** the `db-table`/`db-query`/`db-schema` members of `WorkStationTabCategory` and the `"data"` slot in `StatusBarAppType` remain as unused union members; the `dockFilter.data` i18n key remains in `navigation.json` across locales. diff --git a/src/hooks/database/index.ts b/.archive/src/hooks/database/index.ts similarity index 100% rename from src/hooks/database/index.ts rename to .archive/src/hooks/database/index.ts diff --git a/src/hooks/database/useDatabaseConnections.ts b/.archive/src/hooks/database/useDatabaseConnections.ts similarity index 100% rename from src/hooks/database/useDatabaseConnections.ts rename to .archive/src/hooks/database/useDatabaseConnections.ts diff --git a/src/hooks/database/usePendingChanges.ts b/.archive/src/hooks/database/usePendingChanges.ts similarity index 100% rename from src/hooks/database/usePendingChanges.ts rename to .archive/src/hooks/database/usePendingChanges.ts diff --git a/src/hooks/database/useQueryHistory.ts b/.archive/src/hooks/database/useQueryHistory.ts similarity index 100% rename from src/hooks/database/useQueryHistory.ts rename to .archive/src/hooks/database/useQueryHistory.ts diff --git a/src/hooks/database/useSqliteDatabase.ts b/.archive/src/hooks/database/useSqliteDatabase.ts similarity index 100% rename from src/hooks/database/useSqliteDatabase.ts rename to .archive/src/hooks/database/useSqliteDatabase.ts diff --git a/src/modules/WorkStation/Browser/Panels/BrowserMainPane/components/DesignFileBar/index.tsx b/.archive/src/modules/WorkStation/Browser/Panels/BrowserMainPane/components/DesignFileBar/index.tsx similarity index 100% rename from src/modules/WorkStation/Browser/Panels/BrowserMainPane/components/DesignFileBar/index.tsx rename to .archive/src/modules/WorkStation/Browser/Panels/BrowserMainPane/components/DesignFileBar/index.tsx diff --git a/src/modules/WorkStation/Browser/Panels/BrowserMainPane/content/TokenManagerContent/index.tsx b/.archive/src/modules/WorkStation/Browser/Panels/BrowserMainPane/content/TokenManagerContent/index.tsx similarity index 100% rename from src/modules/WorkStation/Browser/Panels/BrowserMainPane/content/TokenManagerContent/index.tsx rename to .archive/src/modules/WorkStation/Browser/Panels/BrowserMainPane/content/TokenManagerContent/index.tsx diff --git a/src/modules/WorkStation/DatabaseManager/DatabaseLayout/config.ts b/.archive/src/modules/WorkStation/DatabaseManager/DatabaseLayout/config.ts similarity index 100% rename from src/modules/WorkStation/DatabaseManager/DatabaseLayout/config.ts rename to .archive/src/modules/WorkStation/DatabaseManager/DatabaseLayout/config.ts diff --git a/src/modules/WorkStation/DatabaseManager/DatabaseLayout/index.tsx b/.archive/src/modules/WorkStation/DatabaseManager/DatabaseLayout/index.tsx similarity index 100% rename from src/modules/WorkStation/DatabaseManager/DatabaseLayout/index.tsx rename to .archive/src/modules/WorkStation/DatabaseManager/DatabaseLayout/index.tsx diff --git a/.archive/src/modules/WorkStation/DatabaseManager/DatabaseLayout/overlays/AddConnectionModal/AddConnectionFormField.tsx b/.archive/src/modules/WorkStation/DatabaseManager/DatabaseLayout/overlays/AddConnectionModal/AddConnectionFormField.tsx new file mode 100644 index 000000000..c8ab451cd --- /dev/null +++ b/.archive/src/modules/WorkStation/DatabaseManager/DatabaseLayout/overlays/AddConnectionModal/AddConnectionFormField.tsx @@ -0,0 +1,27 @@ +import React from "react"; + +import Input, { InputProps } from "@src/components/Input"; + +interface AddConnectionFormFieldProps extends Omit { + label: React.ReactNode; + onChange: (value: string) => void; + hint?: React.ReactNode; +} + +export function AddConnectionFormField({ + label, + onChange, + hint, + className, + ...inputProps +}: AddConnectionFormFieldProps) { + return ( +
+ + + {hint &&

{hint}

} +
+ ); +} diff --git a/src/modules/WorkStation/DatabaseManager/DatabaseLayout/overlays/AddConnectionModal/AddConnectionModalHeader.tsx b/.archive/src/modules/WorkStation/DatabaseManager/DatabaseLayout/overlays/AddConnectionModal/AddConnectionModalHeader.tsx similarity index 100% rename from src/modules/WorkStation/DatabaseManager/DatabaseLayout/overlays/AddConnectionModal/AddConnectionModalHeader.tsx rename to .archive/src/modules/WorkStation/DatabaseManager/DatabaseLayout/overlays/AddConnectionModal/AddConnectionModalHeader.tsx diff --git a/src/modules/WorkStation/DatabaseManager/DatabaseLayout/overlays/AddConnectionModal/ConnectionNameField.tsx b/.archive/src/modules/WorkStation/DatabaseManager/DatabaseLayout/overlays/AddConnectionModal/ConnectionNameField.tsx similarity index 100% rename from src/modules/WorkStation/DatabaseManager/DatabaseLayout/overlays/AddConnectionModal/ConnectionNameField.tsx rename to .archive/src/modules/WorkStation/DatabaseManager/DatabaseLayout/overlays/AddConnectionModal/ConnectionNameField.tsx diff --git a/src/modules/WorkStation/DatabaseManager/DatabaseLayout/overlays/AddConnectionModal/ConnectionTestStatusBanner.tsx b/.archive/src/modules/WorkStation/DatabaseManager/DatabaseLayout/overlays/AddConnectionModal/ConnectionTestStatusBanner.tsx similarity index 100% rename from src/modules/WorkStation/DatabaseManager/DatabaseLayout/overlays/AddConnectionModal/ConnectionTestStatusBanner.tsx rename to .archive/src/modules/WorkStation/DatabaseManager/DatabaseLayout/overlays/AddConnectionModal/ConnectionTestStatusBanner.tsx diff --git a/src/modules/WorkStation/DatabaseManager/DatabaseLayout/overlays/AddConnectionModal/DatabaseTypeSelector.tsx b/.archive/src/modules/WorkStation/DatabaseManager/DatabaseLayout/overlays/AddConnectionModal/DatabaseTypeSelector.tsx similarity index 100% rename from src/modules/WorkStation/DatabaseManager/DatabaseLayout/overlays/AddConnectionModal/DatabaseTypeSelector.tsx rename to .archive/src/modules/WorkStation/DatabaseManager/DatabaseLayout/overlays/AddConnectionModal/DatabaseTypeSelector.tsx diff --git a/.archive/src/modules/WorkStation/DatabaseManager/DatabaseLayout/overlays/AddConnectionModal/MysqlConnectionFields.tsx b/.archive/src/modules/WorkStation/DatabaseManager/DatabaseLayout/overlays/AddConnectionModal/MysqlConnectionFields.tsx new file mode 100644 index 000000000..c20e751f8 --- /dev/null +++ b/.archive/src/modules/WorkStation/DatabaseManager/DatabaseLayout/overlays/AddConnectionModal/MysqlConnectionFields.tsx @@ -0,0 +1,78 @@ +import { memo } from "react"; +import { useTranslation } from "react-i18next"; + +import { AddConnectionFormField } from "./AddConnectionFormField"; + +export interface MysqlConnectionFieldsProps { + mysqlHost: string; + mysqlPort: string; + mysqlDatabase: string; + mysqlUser: string; + mysqlPassword: string; + onMysqlHostChange: (value: string) => void; + onMysqlPortChange: (value: string) => void; + onMysqlDatabaseChange: (value: string) => void; + onMysqlUserChange: (value: string) => void; + onMysqlPasswordChange: (value: string) => void; +} + +export const MysqlConnectionFields = memo(function MysqlConnectionFields({ + mysqlHost, + mysqlPort, + mysqlDatabase, + mysqlUser, + mysqlPassword, + onMysqlHostChange, + onMysqlPortChange, + onMysqlDatabaseChange, + onMysqlUserChange, + onMysqlPasswordChange, +}: MysqlConnectionFieldsProps) { + const { t } = useTranslation(); + + return ( + <> +
+ + +
+ +
+ + + {t("database.password")}{" "} + ({t("optional")}) + + } + type="password" + value={mysqlPassword} + onChange={onMysqlPasswordChange} + /> +
+ + ); +}); diff --git a/src/modules/WorkStation/DatabaseManager/DatabaseLayout/overlays/AddConnectionModal/NeonConnectionFields.tsx b/.archive/src/modules/WorkStation/DatabaseManager/DatabaseLayout/overlays/AddConnectionModal/NeonConnectionFields.tsx similarity index 100% rename from src/modules/WorkStation/DatabaseManager/DatabaseLayout/overlays/AddConnectionModal/NeonConnectionFields.tsx rename to .archive/src/modules/WorkStation/DatabaseManager/DatabaseLayout/overlays/AddConnectionModal/NeonConnectionFields.tsx diff --git a/.archive/src/modules/WorkStation/DatabaseManager/DatabaseLayout/overlays/AddConnectionModal/PostgresConnectionFields.tsx b/.archive/src/modules/WorkStation/DatabaseManager/DatabaseLayout/overlays/AddConnectionModal/PostgresConnectionFields.tsx new file mode 100644 index 000000000..97fb2c34e --- /dev/null +++ b/.archive/src/modules/WorkStation/DatabaseManager/DatabaseLayout/overlays/AddConnectionModal/PostgresConnectionFields.tsx @@ -0,0 +1,94 @@ +import { memo } from "react"; +import { useTranslation } from "react-i18next"; + +import { AddConnectionFormField } from "./AddConnectionFormField"; + +export interface PostgresConnectionFieldsProps { + pgHost: string; + pgPort: string; + pgDatabase: string; + pgUser: string; + pgPassword: string; + pgSsl: boolean; + onPgHostChange: (value: string) => void; + onPgPortChange: (value: string) => void; + onPgDatabaseChange: (value: string) => void; + onPgUserChange: (value: string) => void; + onPgPasswordChange: (value: string) => void; + onPgSslChange: (value: boolean) => void; +} + +export const PostgresConnectionFields = memo(function PostgresConnectionFields({ + pgHost, + pgPort, + pgDatabase, + pgUser, + pgPassword, + pgSsl, + onPgHostChange, + onPgPortChange, + onPgDatabaseChange, + onPgUserChange, + onPgPasswordChange, + onPgSslChange, +}: PostgresConnectionFieldsProps) { + const { t } = useTranslation(); + + return ( + <> +
+ + +
+ +
+ + + {t("database.password")}{" "} + ({t("optional")}) + + } + type="password" + value={pgPassword} + onChange={onPgPasswordChange} + /> +
+
+ onPgSslChange(event.target.checked)} + className="h-4 w-4 rounded border-border-2" + /> + +
+ + ); +}); diff --git a/src/modules/WorkStation/DatabaseManager/DatabaseLayout/overlays/AddConnectionModal/SqliteConnectionFields.tsx b/.archive/src/modules/WorkStation/DatabaseManager/DatabaseLayout/overlays/AddConnectionModal/SqliteConnectionFields.tsx similarity index 100% rename from src/modules/WorkStation/DatabaseManager/DatabaseLayout/overlays/AddConnectionModal/SqliteConnectionFields.tsx rename to .archive/src/modules/WorkStation/DatabaseManager/DatabaseLayout/overlays/AddConnectionModal/SqliteConnectionFields.tsx diff --git a/.archive/src/modules/WorkStation/DatabaseManager/DatabaseLayout/overlays/AddConnectionModal/SupabaseConnectionFields.tsx b/.archive/src/modules/WorkStation/DatabaseManager/DatabaseLayout/overlays/AddConnectionModal/SupabaseConnectionFields.tsx new file mode 100644 index 000000000..b5c26c082 --- /dev/null +++ b/.archive/src/modules/WorkStation/DatabaseManager/DatabaseLayout/overlays/AddConnectionModal/SupabaseConnectionFields.tsx @@ -0,0 +1,54 @@ +import { memo } from "react"; +import { useTranslation } from "react-i18next"; + +import { AddConnectionFormField } from "./AddConnectionFormField"; + +export interface SupabaseConnectionFieldsProps { + supabaseUrl: string; + supabaseAccessToken: string; + onSupabaseUrlChange: (value: string) => void; + onSupabaseAccessTokenChange: (value: string) => void; +} + +export const SupabaseConnectionFields = memo(function SupabaseConnectionFields({ + supabaseUrl, + supabaseAccessToken, + onSupabaseUrlChange, + onSupabaseAccessTokenChange, +}: SupabaseConnectionFieldsProps) { + const { t } = useTranslation(); + + return ( + <> + + + {t("database.supabaseTokenHint")}{" "} + + supabase.com/dashboard/account/tokens + + + } + /> + + ); +}); diff --git a/.archive/src/modules/WorkStation/DatabaseManager/DatabaseLayout/overlays/AddConnectionModal/TursoConnectionFields.tsx b/.archive/src/modules/WorkStation/DatabaseManager/DatabaseLayout/overlays/AddConnectionModal/TursoConnectionFields.tsx new file mode 100644 index 000000000..5f0304ac3 --- /dev/null +++ b/.archive/src/modules/WorkStation/DatabaseManager/DatabaseLayout/overlays/AddConnectionModal/TursoConnectionFields.tsx @@ -0,0 +1,46 @@ +import { memo } from "react"; +import { useTranslation } from "react-i18next"; + +import { AddConnectionFormField } from "./AddConnectionFormField"; + +export interface TursoConnectionFieldsProps { + tursoUrl: string; + tursoToken: string; + onTursoUrlChange: (value: string) => void; + onTursoTokenChange: (value: string) => void; +} + +export const TursoConnectionFields = memo(function TursoConnectionFields({ + tursoUrl, + tursoToken, + onTursoUrlChange, + onTursoTokenChange, +}: TursoConnectionFieldsProps) { + const { t } = useTranslation(); + + return ( + <> + + + {t("database.authToken")}{" "} + ({t("optional")}) + + } + type="password" + value={tursoToken} + onChange={onTursoTokenChange} + placeholder="eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9..." + hint={t("database.tursoTokenHint")} + /> + + ); +}); diff --git a/src/modules/WorkStation/DatabaseManager/DatabaseLayout/overlays/AddConnectionModal/databaseTypeOptions.tsx b/.archive/src/modules/WorkStation/DatabaseManager/DatabaseLayout/overlays/AddConnectionModal/databaseTypeOptions.tsx similarity index 100% rename from src/modules/WorkStation/DatabaseManager/DatabaseLayout/overlays/AddConnectionModal/databaseTypeOptions.tsx rename to .archive/src/modules/WorkStation/DatabaseManager/DatabaseLayout/overlays/AddConnectionModal/databaseTypeOptions.tsx diff --git a/src/modules/WorkStation/DatabaseManager/DatabaseLayout/overlays/AddConnectionModal/formInputClass.ts b/.archive/src/modules/WorkStation/DatabaseManager/DatabaseLayout/overlays/AddConnectionModal/formInputClass.ts similarity index 100% rename from src/modules/WorkStation/DatabaseManager/DatabaseLayout/overlays/AddConnectionModal/formInputClass.ts rename to .archive/src/modules/WorkStation/DatabaseManager/DatabaseLayout/overlays/AddConnectionModal/formInputClass.ts diff --git a/src/modules/WorkStation/DatabaseManager/DatabaseLayout/overlays/AddConnectionModal/index.scss b/.archive/src/modules/WorkStation/DatabaseManager/DatabaseLayout/overlays/AddConnectionModal/index.scss similarity index 100% rename from src/modules/WorkStation/DatabaseManager/DatabaseLayout/overlays/AddConnectionModal/index.scss rename to .archive/src/modules/WorkStation/DatabaseManager/DatabaseLayout/overlays/AddConnectionModal/index.scss diff --git a/src/modules/WorkStation/DatabaseManager/DatabaseLayout/overlays/AddConnectionModal/index.tsx b/.archive/src/modules/WorkStation/DatabaseManager/DatabaseLayout/overlays/AddConnectionModal/index.tsx similarity index 100% rename from src/modules/WorkStation/DatabaseManager/DatabaseLayout/overlays/AddConnectionModal/index.tsx rename to .archive/src/modules/WorkStation/DatabaseManager/DatabaseLayout/overlays/AddConnectionModal/index.tsx diff --git a/src/modules/WorkStation/DatabaseManager/DatabaseLayout/overlays/AddConnectionModal/types.ts b/.archive/src/modules/WorkStation/DatabaseManager/DatabaseLayout/overlays/AddConnectionModal/types.ts similarity index 100% rename from src/modules/WorkStation/DatabaseManager/DatabaseLayout/overlays/AddConnectionModal/types.ts rename to .archive/src/modules/WorkStation/DatabaseManager/DatabaseLayout/overlays/AddConnectionModal/types.ts diff --git a/src/modules/WorkStation/DatabaseManager/Panels/DatabaseMainPane/components/DataGrid/ActionBar.tsx b/.archive/src/modules/WorkStation/DatabaseManager/Panels/DatabaseMainPane/components/DataGrid/ActionBar.tsx similarity index 100% rename from src/modules/WorkStation/DatabaseManager/Panels/DatabaseMainPane/components/DataGrid/ActionBar.tsx rename to .archive/src/modules/WorkStation/DatabaseManager/Panels/DatabaseMainPane/components/DataGrid/ActionBar.tsx diff --git a/src/modules/WorkStation/DatabaseManager/Panels/DatabaseMainPane/components/DataGrid/InlineEditCell.tsx b/.archive/src/modules/WorkStation/DatabaseManager/Panels/DatabaseMainPane/components/DataGrid/InlineEditCell.tsx similarity index 100% rename from src/modules/WorkStation/DatabaseManager/Panels/DatabaseMainPane/components/DataGrid/InlineEditCell.tsx rename to .archive/src/modules/WorkStation/DatabaseManager/Panels/DatabaseMainPane/components/DataGrid/InlineEditCell.tsx diff --git a/src/modules/WorkStation/DatabaseManager/Panels/DatabaseMainPane/components/DataGrid/InsertRowModal.tsx b/.archive/src/modules/WorkStation/DatabaseManager/Panels/DatabaseMainPane/components/DataGrid/InsertRowModal.tsx similarity index 100% rename from src/modules/WorkStation/DatabaseManager/Panels/DatabaseMainPane/components/DataGrid/InsertRowModal.tsx rename to .archive/src/modules/WorkStation/DatabaseManager/Panels/DatabaseMainPane/components/DataGrid/InsertRowModal.tsx diff --git a/src/modules/WorkStation/DatabaseManager/Panels/DatabaseMainPane/components/DataGrid/index.scss b/.archive/src/modules/WorkStation/DatabaseManager/Panels/DatabaseMainPane/components/DataGrid/index.scss similarity index 100% rename from src/modules/WorkStation/DatabaseManager/Panels/DatabaseMainPane/components/DataGrid/index.scss rename to .archive/src/modules/WorkStation/DatabaseManager/Panels/DatabaseMainPane/components/DataGrid/index.scss diff --git a/src/modules/WorkStation/DatabaseManager/Panels/DatabaseMainPane/components/DataGrid/index.tsx b/.archive/src/modules/WorkStation/DatabaseManager/Panels/DatabaseMainPane/components/DataGrid/index.tsx similarity index 100% rename from src/modules/WorkStation/DatabaseManager/Panels/DatabaseMainPane/components/DataGrid/index.tsx rename to .archive/src/modules/WorkStation/DatabaseManager/Panels/DatabaseMainPane/components/DataGrid/index.tsx diff --git a/src/modules/WorkStation/DatabaseManager/Panels/DatabaseMainPane/config.ts b/.archive/src/modules/WorkStation/DatabaseManager/Panels/DatabaseMainPane/config.ts similarity index 100% rename from src/modules/WorkStation/DatabaseManager/Panels/DatabaseMainPane/config.ts rename to .archive/src/modules/WorkStation/DatabaseManager/Panels/DatabaseMainPane/config.ts diff --git a/src/modules/WorkStation/DatabaseManager/Panels/DatabaseMainPane/index.tsx b/.archive/src/modules/WorkStation/DatabaseManager/Panels/DatabaseMainPane/index.tsx similarity index 100% rename from src/modules/WorkStation/DatabaseManager/Panels/DatabaseMainPane/index.tsx rename to .archive/src/modules/WorkStation/DatabaseManager/Panels/DatabaseMainPane/index.tsx diff --git a/src/modules/WorkStation/DatabaseManager/Panels/DatabaseMainPane/types.ts b/.archive/src/modules/WorkStation/DatabaseManager/Panels/DatabaseMainPane/types.ts similarity index 100% rename from src/modules/WorkStation/DatabaseManager/Panels/DatabaseMainPane/types.ts rename to .archive/src/modules/WorkStation/DatabaseManager/Panels/DatabaseMainPane/types.ts diff --git a/src/modules/WorkStation/DatabaseManager/Panels/DatabasePrimarySidebar/config.ts b/.archive/src/modules/WorkStation/DatabaseManager/Panels/DatabasePrimarySidebar/config.ts similarity index 100% rename from src/modules/WorkStation/DatabaseManager/Panels/DatabasePrimarySidebar/config.ts rename to .archive/src/modules/WorkStation/DatabaseManager/Panels/DatabasePrimarySidebar/config.ts diff --git a/src/modules/WorkStation/DatabaseManager/Panels/DatabasePrimarySidebar/content/ConnectionsContent/components/AddedConnectionsList.tsx b/.archive/src/modules/WorkStation/DatabaseManager/Panels/DatabasePrimarySidebar/content/ConnectionsContent/components/AddedConnectionsList.tsx similarity index 100% rename from src/modules/WorkStation/DatabaseManager/Panels/DatabasePrimarySidebar/content/ConnectionsContent/components/AddedConnectionsList.tsx rename to .archive/src/modules/WorkStation/DatabaseManager/Panels/DatabasePrimarySidebar/content/ConnectionsContent/components/AddedConnectionsList.tsx diff --git a/src/modules/WorkStation/DatabaseManager/Panels/DatabasePrimarySidebar/content/ConnectionsContent/components/PendingConnectionsList.tsx b/.archive/src/modules/WorkStation/DatabaseManager/Panels/DatabasePrimarySidebar/content/ConnectionsContent/components/PendingConnectionsList.tsx similarity index 100% rename from src/modules/WorkStation/DatabaseManager/Panels/DatabasePrimarySidebar/content/ConnectionsContent/components/PendingConnectionsList.tsx rename to .archive/src/modules/WorkStation/DatabaseManager/Panels/DatabasePrimarySidebar/content/ConnectionsContent/components/PendingConnectionsList.tsx diff --git a/src/modules/WorkStation/DatabaseManager/Panels/DatabasePrimarySidebar/content/ConnectionsContent/index.tsx b/.archive/src/modules/WorkStation/DatabaseManager/Panels/DatabasePrimarySidebar/content/ConnectionsContent/index.tsx similarity index 100% rename from src/modules/WorkStation/DatabaseManager/Panels/DatabasePrimarySidebar/content/ConnectionsContent/index.tsx rename to .archive/src/modules/WorkStation/DatabaseManager/Panels/DatabasePrimarySidebar/content/ConnectionsContent/index.tsx diff --git a/src/modules/WorkStation/DatabaseManager/Panels/DatabasePrimarySidebar/content/QueryHistoryContent/index.tsx b/.archive/src/modules/WorkStation/DatabaseManager/Panels/DatabasePrimarySidebar/content/QueryHistoryContent/index.tsx similarity index 100% rename from src/modules/WorkStation/DatabaseManager/Panels/DatabasePrimarySidebar/content/QueryHistoryContent/index.tsx rename to .archive/src/modules/WorkStation/DatabaseManager/Panels/DatabasePrimarySidebar/content/QueryHistoryContent/index.tsx diff --git a/src/modules/WorkStation/DatabaseManager/Panels/DatabasePrimarySidebar/hooks/useDatabaseSidebarState.ts b/.archive/src/modules/WorkStation/DatabaseManager/Panels/DatabasePrimarySidebar/hooks/useDatabaseSidebarState.ts similarity index 100% rename from src/modules/WorkStation/DatabaseManager/Panels/DatabasePrimarySidebar/hooks/useDatabaseSidebarState.ts rename to .archive/src/modules/WorkStation/DatabaseManager/Panels/DatabasePrimarySidebar/hooks/useDatabaseSidebarState.ts diff --git a/src/modules/WorkStation/DatabaseManager/Panels/DatabasePrimarySidebar/index.tsx b/.archive/src/modules/WorkStation/DatabaseManager/Panels/DatabasePrimarySidebar/index.tsx similarity index 100% rename from src/modules/WorkStation/DatabaseManager/Panels/DatabasePrimarySidebar/index.tsx rename to .archive/src/modules/WorkStation/DatabaseManager/Panels/DatabasePrimarySidebar/index.tsx diff --git a/src/modules/WorkStation/DatabaseManager/Panels/DatabasePrimarySidebar/tabs/ConnectionsTab.tsx b/.archive/src/modules/WorkStation/DatabaseManager/Panels/DatabasePrimarySidebar/tabs/ConnectionsTab.tsx similarity index 100% rename from src/modules/WorkStation/DatabaseManager/Panels/DatabasePrimarySidebar/tabs/ConnectionsTab.tsx rename to .archive/src/modules/WorkStation/DatabaseManager/Panels/DatabasePrimarySidebar/tabs/ConnectionsTab.tsx diff --git a/src/modules/WorkStation/DatabaseManager/Panels/DatabasePrimarySidebar/tabs/QueryHistoryTab.tsx b/.archive/src/modules/WorkStation/DatabaseManager/Panels/DatabasePrimarySidebar/tabs/QueryHistoryTab.tsx similarity index 100% rename from src/modules/WorkStation/DatabaseManager/Panels/DatabasePrimarySidebar/tabs/QueryHistoryTab.tsx rename to .archive/src/modules/WorkStation/DatabaseManager/Panels/DatabasePrimarySidebar/tabs/QueryHistoryTab.tsx diff --git a/src/modules/WorkStation/DatabaseManager/Panels/DatabasePrimarySidebar/types.ts b/.archive/src/modules/WorkStation/DatabaseManager/Panels/DatabasePrimarySidebar/types.ts similarity index 100% rename from src/modules/WorkStation/DatabaseManager/Panels/DatabasePrimarySidebar/types.ts rename to .archive/src/modules/WorkStation/DatabaseManager/Panels/DatabasePrimarySidebar/types.ts diff --git a/src/modules/WorkStation/DatabaseManager/config.ts b/.archive/src/modules/WorkStation/DatabaseManager/config.ts similarity index 100% rename from src/modules/WorkStation/DatabaseManager/config.ts rename to .archive/src/modules/WorkStation/DatabaseManager/config.ts diff --git a/src/modules/WorkStation/DatabaseManager/index.tsx b/.archive/src/modules/WorkStation/DatabaseManager/index.tsx similarity index 100% rename from src/modules/WorkStation/DatabaseManager/index.tsx rename to .archive/src/modules/WorkStation/DatabaseManager/index.tsx diff --git a/src/modules/WorkStation/TabContent/renderers/addConnection.tsx b/.archive/src/modules/WorkStation/TabContent/renderers/addConnection.tsx similarity index 100% rename from src/modules/WorkStation/TabContent/renderers/addConnection.tsx rename to .archive/src/modules/WorkStation/TabContent/renderers/addConnection.tsx diff --git a/src/modules/WorkStation/TabContent/renderers/query.tsx b/.archive/src/modules/WorkStation/TabContent/renderers/query.tsx similarity index 100% rename from src/modules/WorkStation/TabContent/renderers/query.tsx rename to .archive/src/modules/WorkStation/TabContent/renderers/query.tsx diff --git a/src/modules/WorkStation/TabContent/renderers/schema.tsx b/.archive/src/modules/WorkStation/TabContent/renderers/schema.tsx similarity index 100% rename from src/modules/WorkStation/TabContent/renderers/schema.tsx rename to .archive/src/modules/WorkStation/TabContent/renderers/schema.tsx diff --git a/src/modules/WorkStation/TabContent/renderers/table.tsx b/.archive/src/modules/WorkStation/TabContent/renderers/table.tsx similarity index 100% rename from src/modules/WorkStation/TabContent/renderers/table.tsx rename to .archive/src/modules/WorkStation/TabContent/renderers/table.tsx diff --git a/src/modules/WorkStation/TabContent/renderers/tokenCategory.tsx b/.archive/src/modules/WorkStation/TabContent/renderers/tokenCategory.tsx similarity index 100% rename from src/modules/WorkStation/TabContent/renderers/tokenCategory.tsx rename to .archive/src/modules/WorkStation/TabContent/renderers/tokenCategory.tsx diff --git a/src/modules/WorkStation/shared/StatusBar/DatabaseStatusBar.tsx b/.archive/src/modules/WorkStation/shared/StatusBar/DatabaseStatusBar.tsx similarity index 100% rename from src/modules/WorkStation/shared/StatusBar/DatabaseStatusBar.tsx rename to .archive/src/modules/WorkStation/shared/StatusBar/DatabaseStatusBar.tsx diff --git a/src/store/workstation/tabs/factories/database.ts b/.archive/src/store/workstation/tabs/factories/database.ts similarity index 100% rename from src/store/workstation/tabs/factories/database.ts rename to .archive/src/store/workstation/tabs/factories/database.ts diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 30f6b7257..a2a1df86c 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -4,16 +4,11 @@ ## Test plan -- [ ] I ran the checks relevant to the changed files, or explained why they were not run. +- -## Contributor checklist +## Submit checklist -- [ ] The PR title uses scoped Conventional Commits format, such as `feat(scope): summary` or `fix(scope): summary`. -- [ ] All commits use scoped Conventional Commits format. -- [ ] I did not skip pre-commit, pre-push, lint-staged, or other repository hooks. -- [ ] The PR is scoped to one issue, feature, or fix. -- [ ] I have signed the CLA if prompted by the CLA Assistant bot. -- [ ] A human actively participated in the design and implementation process, and reviewed the contribution before submission. -- [ ] I did not include secrets, private configuration, generated build output, or unrelated formatting changes. -- [ ] I updated documentation for behavior, setup, or architecture changes where needed. -- [ ] I updated all supported locale files for new UI text where needed. +- [ ] The PR is focused and has a scoped Conventional Commits title, such as `feat(scope): summary` or `fix(scope): summary`. +- [ ] I ran the relevant checks, or explained why they were not run. +- [ ] No secrets, private config, generated output, or unrelated formatting changes are included. +- [ ] Docs, screenshots, and locale updates are included when the change needs them. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eedcc907d..0a429fd72 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,8 +1,8 @@ -# CI — runs on every pull request to release / master. +# CI — runs on every pull request to develop / release / master. # # Two parallel jobs: # frontend — TypeScript typecheck + ESLint + Vitest unit tests -# rust — cargo check + clippy (warnings-as-errors) + cargo test +# rust — cargo check + advisory clippy + cargo test # # Mirrors the toolchain versions used in release.yaml (Node 20, pnpm 9, # Rust stable, swatinem/rust-cache) so CI and release builds stay in sync. @@ -12,6 +12,7 @@ name: "CI" on: pull_request: branches: + - develop - release - master @@ -45,12 +46,14 @@ jobs: - name: Type check run: pnpm typecheck + env: + NODE_OPTIONS: "--max-old-space-size=6144" - name: Lint run: pnpm lint - name: Unit tests - run: pnpm test --run + run: pnpm run test # ── Rust ──────────────────────────────────────────────────────────────────── rust: @@ -74,10 +77,17 @@ jobs: working-directory: src-tauri run: cargo check --workspace - - name: cargo clippy + # The current develop baseline has pre-existing Clippy warnings across + # multiple crates. Keep Clippy visible and compilation-blocking without + # letting toolchain lint drift block every feature PR; restore + # `-- -D warnings` after the baseline cleanup lands. + - name: cargo clippy (advisory warnings) working-directory: src-tauri - run: cargo clippy --workspace -- -D warnings + run: cargo clippy --workspace - name: cargo test working-directory: src-tauri - run: cargo test --workspace + # Several agent-core tests intentionally exercise process-global SQLite + # state. Keep the workspace suite deterministic until those fixtures are + # migrated to per-test connections. + run: cargo test --workspace -- --test-threads=1 diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index a008ae3af..cc15bafc0 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -1,9 +1,22 @@ -# Build, sign, notarize, and release ORGII for macOS (Apple Silicon) and Windows (x64). +# Build, sign, notarize, and release ORGII for macOS (Apple Silicon), Windows (x64), and Linux (x64). # # Trigger: push a tag matching v* (e.g. v1.1.0, v1.1.1). # Release tags must be valid three-part SemVer because Tauri app/updater # versions are written directly from the tag. # +# Update channels: +# Stable — tag vX.Y.Z. Served via the GitHub "latest release" alias +# (releases/latest/download/latest.json), which excludes prereleases. +# Beta — tag vX.Y.Z-beta.N for the NEXT unreleased X.Y.Z (never a version +# that already shipped, or SemVer would sort it below stable and the +# updater would never offer it). Marked as a GitHub prerelease. +# Windows beta builds ship NSIS only: WiX/MSI rejects SemVer prerelease +# versions, and the updater uses the NSIS artifact anyway. +# Every release (stable and beta) overwrites beta.json on the rolling +# `updater` release when it is the highest version so far, so the beta +# channel always points at the newest build of either kind and beta users +# converge back onto stable once it overtakes the last beta. +# # Sidecar binaries (peekaboo, agent-browser, dugite/git) are NOT bundled in the # .app. They are downloaded at first launch into ~/.orgii/bin/ by the app itself # (post-notarized download strategy). This keeps the notarized bundle small and @@ -168,14 +181,15 @@ jobs: UPDATER_TAR=$(find "$BUNDLE_DIR/macos" -name "*.app.tar.gz" ! -name "*.sig" | head -1) UPDATER_SIG=$(find "$BUNDLE_DIR/macos" -name "*.app.tar.gz.sig" | head -1) - echo "updater_tar=$UPDATER_TAR" >> "$GITHUB_OUTPUT" + LATEST_UPDATER_TAR="ORG2-updater-mac-apple-silicon.app.tar.gz" + cp "$UPDATER_TAR" "$LATEST_UPDATER_TAR" + echo "updater_tar=$LATEST_UPDATER_TAR" >> "$GITHUB_OUTPUT" echo "updater_sig=$UPDATER_SIG" >> "$GITHUB_OUTPUT" - echo "updater_tar_name=$(basename "$UPDATER_TAR")" >> "$GITHUB_OUTPUT" - echo "updater_sig_name=$(basename "$UPDATER_SIG")" >> "$GITHUB_OUTPUT" + echo "updater_tar_name=$LATEST_UPDATER_TAR" >> "$GITHUB_OUTPUT" echo "=== Release artifacts ===" echo "DMG: $DMG" - echo "Updater tar: $UPDATER_TAR" + echo "Updater tar: $LATEST_UPDATER_TAR" echo "Updater sig: $UPDATER_SIG" # ── Generate updater manifest (latest.json) ─────────────── @@ -212,10 +226,8 @@ jobs: prerelease: ${{ contains(github.ref_name, '-rc') || contains(github.ref_name, '-beta') || contains(github.ref_name, '-alpha') || contains(github.ref_name, '-nightly') }} generate_release_notes: true files: | - ${{ steps.artifacts.outputs.dmg }} ${{ steps.artifacts.outputs.latest_dmg }} ${{ steps.artifacts.outputs.updater_tar }} - ${{ steps.artifacts.outputs.updater_sig }} latest.json # ── Cleanup keychain ──────────────────────────────────────── @@ -256,6 +268,15 @@ jobs: SEMVER="$FULL_VERSION" echo "VERSION=$FULL_VERSION" >> "$GITHUB_ENV" echo "SEMVER=$SEMVER" >> "$GITHUB_ENV" + # WiX/MSI rejects SemVer prerelease versions, so beta builds ship NSIS only. + # The updater's windows-x86_64 entry uses NSIS either way. + if [[ "$FULL_VERSION" == *-* ]]; then + echo "IS_PRERELEASE=true" >> "$GITHUB_ENV" + echo "WIN_BUNDLES=nsis" >> "$GITHUB_ENV" + else + echo "IS_PRERELEASE=false" >> "$GITHUB_ENV" + echo "WIN_BUNDLES=msi,nsis" >> "$GITHUB_ENV" + fi sed -i "s/\"version\": \".*\"/\"version\": \"$SEMVER\"/" src-tauri/tauri.conf.json sed -i "s/\"version\": \".*\"/\"version\": \"$FULL_VERSION\"/" package.json @@ -291,7 +312,7 @@ jobs: TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} ORGII_DIAGNOSTICS_TOKEN: ${{ secrets.ORGII_DIAGNOSTICS_TOKEN }} ORGII_APP_VERSION: ${{ env.SEMVER }} - run: pnpm tauri build --target x86_64-pc-windows-msvc + run: pnpm tauri build --target x86_64-pc-windows-msvc --bundles ${{ env.WIN_BUNDLES }} # ── Sign with Azure Trusted Signing ───────────────────────── - name: Azure login (OIDC) @@ -316,6 +337,36 @@ jobs: timestamp-rfc3161: http://timestamp.acs.microsoft.com timestamp-digest: SHA256 + # ── Regenerate updater signatures after code signing ───────── + - name: Regenerate updater signatures + shell: bash + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: | + BUNDLE_DIR="src-tauri/target/x86_64-pc-windows-msvc/release/bundle" + # The msi directory does not exist on prerelease builds (nsis-only). + # This step runs under bash -eo pipefail, so a bare find would kill + # the whole script the moment it exits non-zero. + MSI=$(find "$BUNDLE_DIR/msi" -name "*.msi" ! -name "*.zip" 2>/dev/null | head -1 || true) + NSIS=$(find "$BUNDLE_DIR/nsis" -name "*.exe" ! -name "*.zip" | head -1) + + if [ ! -f "$NSIS" ]; then + echo "Missing Windows updater artifact: $NSIS" >&2 + exit 1 + fi + if [ "$IS_PRERELEASE" != "true" ] && [ ! -f "$MSI" ]; then + echo "Missing Windows updater artifact: $MSI" >&2 + exit 1 + fi + + rm -f "$NSIS.sig" + pnpm tauri signer sign "$NSIS" + if [ -f "$MSI" ]; then + rm -f "$MSI.sig" + pnpm tauri signer sign "$MSI" + fi + # ── Gather artifacts ───────────────────────────────────────── - name: Gather release artifacts id: artifacts @@ -323,43 +374,261 @@ jobs: run: | BUNDLE_DIR="src-tauri/target/x86_64-pc-windows-msvc/release/bundle" - MSI=$(find "$BUNDLE_DIR/msi" -name "*.msi" ! -name "*.zip" | head -1) - MSI_ZIP=$(find "$BUNDLE_DIR/msi" -name "*.msi.zip" | head -1) - MSI_SIG=$(find "$BUNDLE_DIR/msi" -name "*.msi.zip.sig" | head -1) + # msi directory is absent on prerelease builds; see note in the + # signature-regeneration step about bash -eo pipefail. + MSI=$(find "$BUNDLE_DIR/msi" -name "*.msi" ! -name "*.zip" 2>/dev/null | head -1 || true) + MSI_SIG=$(find "$BUNDLE_DIR/msi" -name "*.msi.sig" 2>/dev/null | head -1 || true) NSIS=$(find "$BUNDLE_DIR/nsis" -name "*.exe" ! -name "*.zip" | head -1) - NSIS_ZIP=$(find "$BUNDLE_DIR/nsis" -name "*.nsis.zip" | head -1) - NSIS_SIG=$(find "$BUNDLE_DIR/nsis" -name "*.nsis.zip.sig" | head -1) + NSIS_SIG=$(find "$BUNDLE_DIR/nsis" -name "*.exe.sig" | head -1) + + for artifact in "$NSIS" "$NSIS_SIG"; do + if [ ! -f "$artifact" ]; then + echo "Missing Windows release artifact: $artifact" >&2 + exit 1 + fi + done + + if [ "$IS_PRERELEASE" != "true" ]; then + for artifact in "$MSI" "$MSI_SIG"; do + if [ ! -f "$artifact" ]; then + echo "Missing Windows release artifact: $artifact" >&2 + exit 1 + fi + done + fi - LATEST_MSI="ORG2-latest-windows-x64.msi" LATEST_NSIS="ORG2-latest-windows-x64-setup.exe" - cp "$MSI" "$LATEST_MSI" cp "$NSIS" "$LATEST_NSIS" - echo "msi=$MSI" >> "$GITHUB_OUTPUT" - echo "msi_zip=$MSI_ZIP" >> "$GITHUB_OUTPUT" - echo "msi_sig=$MSI_SIG" >> "$GITHUB_OUTPUT" + if [ -f "$MSI" ]; then + LATEST_MSI="ORG2-latest-windows-x64.msi" + cp "$MSI" "$LATEST_MSI" + echo "msi=$MSI" >> "$GITHUB_OUTPUT" + echo "msi_sig=$MSI_SIG" >> "$GITHUB_OUTPUT" + echo "latest_msi=$LATEST_MSI" >> "$GITHUB_OUTPUT" + fi + echo "nsis=$NSIS" >> "$GITHUB_OUTPUT" - echo "nsis_zip=$NSIS_ZIP" >> "$GITHUB_OUTPUT" echo "nsis_sig=$NSIS_SIG" >> "$GITHUB_OUTPUT" - echo "latest_msi=$LATEST_MSI" >> "$GITHUB_OUTPUT" echo "latest_nsis=$LATEST_NSIS" >> "$GITHUB_OUTPUT" echo "=== Windows artifacts ===" - echo "MSI: $MSI" - echo "NSIS: $NSIS" + echo "MSI: ${MSI:-}" + echo "MSI sig: ${MSI_SIG:-}" + echo "NSIS: $NSIS" + echo "NSIS sig: $NSIS_SIG" + + # ── Merge Windows updater entry into latest.json ───────────── + - name: Generate latest.json for Windows updater + env: + TAG: ${{ github.ref_name }} + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + gh release download "$TAG" --pattern latest.json --output latest.json + + SIGNATURE=$(cat "${{ steps.artifacts.outputs.nsis_sig }}") + NSIS_NAME="${{ steps.artifacts.outputs.latest_nsis }}" + DOWNLOAD_URL="https://github.com/${{ github.repository }}/releases/download/${TAG}/${NSIS_NAME}" + + jq \ + --arg signature "$SIGNATURE" \ + --arg url "$DOWNLOAD_URL" \ + '.platforms["windows-x86_64"] = { signature: $signature, url: $url }' \ + latest.json > latest-with-windows.json + + mv latest-with-windows.json latest.json + + echo "=== latest.json with Windows updater ===" + cat latest.json # ── Upload to existing GitHub Release ─────────────────────── - name: Upload Windows artifacts to release uses: softprops/action-gh-release@v3 with: draft: false + overwrite_files: true prerelease: ${{ contains(github.ref_name, '-rc') || contains(github.ref_name, '-beta') || contains(github.ref_name, '-alpha') || contains(github.ref_name, '-nightly') }} files: | - ${{ steps.artifacts.outputs.msi }} ${{ steps.artifacts.outputs.latest_msi }} - ${{ steps.artifacts.outputs.msi_zip }} - ${{ steps.artifacts.outputs.msi_sig }} - ${{ steps.artifacts.outputs.nsis }} ${{ steps.artifacts.outputs.latest_nsis }} - ${{ steps.artifacts.outputs.nsis_zip }} - ${{ steps.artifacts.outputs.nsis_sig }} + latest.json + + # ── Linux x64 build ─────────────────────────────────────────────────── + build-linux: + name: Build & Release (Linux x64) + runs-on: ubuntu-22.04 + needs: build-windows + steps: + # ── Checkout ──────────────────────────────────────────────── + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + # ── Stamp version from git tag ─────────────────────────────── + - name: Set version from tag + run: | + FULL_VERSION="${GITHUB_REF_NAME#v}" + if ! [[ "$FULL_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$ ]]; then + echo "Release tag must be valid three-part SemVer: $GITHUB_REF_NAME" >&2 + exit 1 + fi + SEMVER="$FULL_VERSION" + echo "VERSION=$FULL_VERSION" >> "$GITHUB_ENV" + echo "SEMVER=$SEMVER" >> "$GITHUB_ENV" + sed -i "s/\"version\": \".*\"/\"version\": \"$SEMVER\"/" src-tauri/tauri.conf.json + sed -i "s/\"version\": \".*\"/\"version\": \"$FULL_VERSION\"/" package.json + + # ── System dependencies ────────────────────────────────────── + - name: Install Linux build dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + libwebkit2gtk-4.1-dev \ + libgtk-3-dev \ + libayatana-appindicator3-dev \ + librsvg2-dev \ + patchelf + + # ── Node.js + pnpm ────────────────────────────────────────── + - name: Setup pnpm + uses: pnpm/action-setup@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: "20" + cache: "pnpm" + + - name: Install frontend dependencies + run: pnpm install --frozen-lockfile + + # ── Rust ──────────────────────────────────────────────────── + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + with: + targets: x86_64-unknown-linux-gnu + + - name: Rust cache + uses: swatinem/rust-cache@v2 + with: + workspaces: "./src-tauri -> target" + shared-key: "release-linux-x64" + + # ── Build Tauri app ────────────────────────────────────────── + - name: Build ORGII + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + ORGII_DIAGNOSTICS_TOKEN: ${{ secrets.ORGII_DIAGNOSTICS_TOKEN }} + ORGII_APP_VERSION: ${{ env.SEMVER }} + run: pnpm tauri build --target x86_64-unknown-linux-gnu --bundles deb,appimage + + # ── Gather artifacts ───────────────────────────────────────── + - name: Gather release artifacts + id: artifacts + run: | + BUNDLE_DIR="src-tauri/target/x86_64-unknown-linux-gnu/release/bundle" + + DEB=$(find "$BUNDLE_DIR/deb" -name "*.deb" | head -1) + APPIMAGE=$(find "$BUNDLE_DIR/appimage" -name "*.AppImage" | head -1) + APPIMAGE_SIG=$(find "$BUNDLE_DIR/appimage" -name "*.AppImage.sig" | head -1) + + for artifact in "$DEB" "$APPIMAGE" "$APPIMAGE_SIG"; do + if [ ! -f "$artifact" ]; then + echo "Missing Linux release artifact: $artifact" >&2 + find "$BUNDLE_DIR" -maxdepth 3 -type f -print >&2 + exit 1 + fi + done + + LATEST_DEB="ORG2-latest-linux-x64.deb" + LATEST_APPIMAGE="ORG2-latest-linux-x64.AppImage" + cp "$DEB" "$LATEST_DEB" + cp "$APPIMAGE" "$LATEST_APPIMAGE" + + echo "appimage_sig=$APPIMAGE_SIG" >> "$GITHUB_OUTPUT" + echo "latest_deb=$LATEST_DEB" >> "$GITHUB_OUTPUT" + echo "latest_appimage=$LATEST_APPIMAGE" >> "$GITHUB_OUTPUT" + echo "latest_appimage_name=$LATEST_APPIMAGE" >> "$GITHUB_OUTPUT" + + echo "=== Linux artifacts ===" + echo "DEB: $DEB" + echo "AppImage: $APPIMAGE" + echo "AppImage sig: $APPIMAGE_SIG" + + # ── Merge Linux updater entry into latest.json ─────────────── + - name: Generate latest.json for Linux updater + env: + TAG: ${{ github.ref_name }} + GH_TOKEN: ${{ github.token }} + run: | + gh release download "$TAG" --pattern latest.json --output latest.json + + SIGNATURE=$(cat "${{ steps.artifacts.outputs.appimage_sig }}") + APPIMAGE_NAME="${{ steps.artifacts.outputs.latest_appimage_name }}" + DOWNLOAD_URL="https://github.com/${{ github.repository }}/releases/download/${TAG}/${APPIMAGE_NAME}" + + jq \ + --arg signature "$SIGNATURE" \ + --arg url "$DOWNLOAD_URL" \ + '.platforms["linux-x86_64"] = { signature: $signature, url: $url }' \ + latest.json > latest-with-linux.json + + mv latest-with-linux.json latest.json + + echo "=== latest.json with Linux updater ===" + cat latest.json + + # ── Upload to existing GitHub Release ─────────────────────── + - name: Upload Linux artifacts to release + uses: softprops/action-gh-release@v3 + with: + draft: false + overwrite_files: true + prerelease: ${{ contains(github.ref_name, '-rc') || contains(github.ref_name, '-beta') || contains(github.ref_name, '-alpha') || contains(github.ref_name, '-nightly') }} + files: | + ${{ steps.artifacts.outputs.latest_deb }} + ${{ steps.artifacts.outputs.latest_appimage }} + latest.json + + # ── Publish beta channel manifest ───────────────────────────── + # The `updater` release is a rolling prerelease that hosts channel + # manifests at a stable URL. Every release (stable and beta) overwrites + # beta.json when it is the highest version so far, so the beta channel + # always tracks the newest build of either kind. The stable channel + # keeps using the releases/latest alias and needs no extra publishing. + - name: Publish beta channel manifest + env: + TAG: ${{ github.ref_name }} + GH_TOKEN: ${{ github.token }} + run: | + if ! gh release view updater >/dev/null 2>&1; then + gh release create updater \ + --title "Updater channel manifests" \ + --notes "Rolling release hosting update-channel manifests (beta.json). Do not delete: installed apps on the beta channel poll its assets." \ + --prerelease \ + --target "$GITHUB_SHA" + fi + + NEW_VERSION=$(jq -r '.version' latest.json) + EXISTING_VERSION=$(gh release download updater --pattern beta.json --output - 2>/dev/null | jq -r '.version // empty' || true) + EXISTING_VERSION="${EXISTING_VERSION:-0.0.0}" + + # Guard against regressing beta.json when an older tag is (re)built + # after a newer one already published (e.g. a stable hotfix behind + # the current beta). semver handles prerelease ordering correctly. + HIGHEST=$(npx --yes semver@7 "$NEW_VERSION" "$EXISTING_VERSION" | tail -1) + if [ -z "$HIGHEST" ]; then + echo "semver comparison produced no output (npx failure?); refusing to guess" >&2 + exit 1 + fi + if [ "$HIGHEST" != "$NEW_VERSION" ]; then + echo "Skipping beta.json: existing $EXISTING_VERSION > new $NEW_VERSION" + exit 0 + fi + + cp latest.json beta.json + gh release upload updater beta.json --clobber + echo "Published beta.json for $NEW_VERSION (was $EXISTING_VERSION)" diff --git a/.gitignore b/.gitignore index de5284d68..9531804e0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules/ +.pnpm-store/ /dist/ /build/ coverage/ @@ -135,3 +136,5 @@ code-server-bin/ BitFun/ archive/ **/.build/ +# Codex CLI local state (created when codex runs inside the repo) +.codex/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..84491962f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,74 @@ +# AGENTS.md — Agent Skill Routing for ORGII + +This file orients Codex / orgii agents working in this repo. It tells you **which audit / methodology skill to invoke** for which kind of task, and what to deliver before declaring work done. + +> Cursor IDE users: live UI-feature delivery rules live in `.cursor/rules/ui-feature-workflow.mdc`. This file does **not** replace those — it's about skill routing for AI agents, not unit-test gates. + +This is **advisory**, not a hard contract. Use judgment based on PR size and risk. + +--- + +## Skill Routing Table + +| Scenario | Skill to invoke | When | +| -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- | +| Rust / TypeScript architecture, types, dead code, FSM, naming overload, wire protocol, init parity | `architecture-audit` | Before finalizing a refactor plan; before cleanup/unification PRs; when reviewing a domain rewrite | +| Frontend UI consistency, design-system component usage, arbitrary Tailwind values, a11y basics, visual-pattern duplication | `frontend-ui-audit` | Before delivering a PR that touches `*.tsx` under `src/components/` or `src/modules/**/components/` (component refactors, UI cleanup batches) | +| Both layers change together (e.g. "refactor module X") | Run both, emit **two independent reports** | Don't fold them; each skill has its own decision rules | +| E2E test surface (Playwright / WebDriver), test stability | `e2e-testing` | When adding or repairing rendered E2E specs | + +Skills live at: + +- `~/.orgii/skills/architecture-audit/SKILL.md` (user-global) +- `~/.orgii/skills/frontend-ui-audit/SKILL.md` (user-global) +- `.orgii/skills/architecture-audit/SKILL.md` (workspace copy, if present) +- `.orgii/skills/e2e-testing/SKILL.md` (workspace) + +If the skill block isn't already prefetched in your context, read its `SKILL.md` before acting on it. + +--- + +## Default Delivery Flow + +### Touching `*.tsx` files (UI work) + +Before declaring a UI-touching task complete, ask: + +1. **Is this a single-file bug fix?** If yes, skip `frontend-ui-audit` (its own "When NOT To Use" rules out single bug fixes — noise-to-value ratio is too high). +2. **Is this a component refactor, UI cleanup, or "should this use the design system?" question?** If yes, run `frontend-ui-audit` over the changed files and drop a report in `docs/frontend-ui-audit-YYYY-MM-DD/.md` using the skill's output format. Summarize fix / keep-with-reason / abstract counts in the delivery message so the user can see verdicts without opening the file. +3. **Did you find a fix-candidate that spans multiple files?** Don't fix site-by-site silently. Surface it as a sweep candidate per the skill's `Systematic Sweep Discipline` section and let the user decide whether to land a config-level change. + +### Touching Rust / backend / type-level / cross-layer code + +Before finalizing a refactor plan, walk the 10-layer `architecture-audit` checklist (or at least the layers the change clearly touches). State which layers you covered and which you intentionally skipped. + +### Touching both + +Don't merge the reports. Two skills, two reports. Cross-reference in the delivery message if relevant. + +--- + +## What This File Does NOT Do + +- It does **not** force every PR to produce an audit report. Single bug fixes, copy tweaks, hotfix patches → just ship. +- It does **not** override the skills' own `When NOT To Use` rules. +- It does **not** replace `.cursor/rules/ui-feature-workflow.mdc` for human/Cursor flow (unit tests + TEST_CASES.md + acceptance criteria). Those gates are about delivery quality; this routing is about which methodology to apply. +- It does **not** mandate any commit-message format (commitlint handles that), any lint rule, or any pre-commit hook. Audit reports are docs, not gates. +- It does **not** lock in skill content. If `~/.orgii/skills/*/SKILL.md` updates, this file's routing still applies — read the current SKILL.md, not your memory of it. + +--- + +## Audit Report Conventions + +- **Location:** `docs/-YYYY-MM-DD/.md` (one date-stamped folder per audit batch, one file per audited component). +- **Format:** follow the `## Output Format` section in the relevant skill verbatim — tables with Line / Element / Verdict / Reason / Suggested change columns. +- **`keep with reason` rows MUST fill the Reason column.** That's the audit's value-add — preventing the next pass from re-flagging the same hit. +- **Don't modify source code in an audit-only PR.** Audit and fix are separate concerns; mixing them makes review impossible. + +--- + +## When You're Unsure + +- If you don't know which skill applies, **lean toward running `frontend-ui-audit` for UI changes and `architecture-audit` for type/control-flow changes**. Both being run when only one was needed costs nothing; missing one is a real gap. +- If you're certain the user wants direct implementation and not an audit (e.g. "just fix this bug"), do that — don't insert an audit pass unprompted. +- If the user asks "why didn't audit catch X?", check whether X is in scope for the skill they're invoking before assuming the audit failed. (`architecture-audit` is type/architecture, not UI consistency — see `frontend-ui-audit` for the latter.) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c79e0064e..4f3b23b3a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,83 +2,56 @@ Thank you for helping improve ORGII. This project is a Tauri v2 desktop app built with React, TypeScript, webpack, and Rust. -## Code of conduct +## Quick path -Be respectful, constructive, and specific. Assume good intent, but do not tolerate harassment, discrimination, or personal attacks. Keep issue and pull request discussions focused on the work. +1. Search existing issues and pull requests first. +2. For larger changes, open an issue or discussion before implementing. +3. Set up the app, make a small focused change, and run the checks that match your files. +4. Open a pull request with a clear summary, test plan, and passing CLA check. -## Before you start - -- Search existing issues and pull requests before opening a new one. -- For larger changes, open an issue or discussion first so maintainers can confirm the direction. -- Keep pull requests small and focused. One fix or feature per PR is easiest to review. -- Do not include secrets, private credentials, proprietary data, generated artifacts, or unrelated formatting changes. -- If you use AI assistance, make sure a human actively participates in the design and implementation process and reviews the contribution before submission. +Keep secrets, private data, generated artifacts, and unrelated formatting changes out of your PR. If you use AI assistance, a human must actively review the design and implementation before submission. ## Development setup -Prerequisites: +Install the required tools: -| Tool | Required version | Install | -| ------------------------------------ | ---------------------- | ------------------------------------------------------------------------------------------ | -| [Node.js](https://nodejs.org/) | 20 or current LTS | `nvm install --lts` or download from nodejs.org | -| [pnpm](https://pnpm.io/installation) | 9.15 | `npm install -g pnpm@9.15` | -| [Rust toolchain](https://rustup.rs/) | 1.85.0 or later (MSRV) | `rustup toolchain install stable` | -| Tauri system dependencies | — | Follow the [Tauri prerequisites guide](https://tauri.app/start/prerequisites/) for your OS | -| [Python 3](https://www.python.org/) | any 3.x | Only needed for optional asset download scripts | +| Tool | Version | Notes | +| ------------------------------------ | ----------------- | ------------------------------------------------------------------------------------------- | +| [Node.js](https://nodejs.org/) | 20 or current LTS | Use `nvm install --lts` or the Node.js installer. | +| [pnpm](https://pnpm.io/installation) | 9.15 | Install with `npm install -g pnpm@9.15`. | +| [Rust toolchain](https://rustup.rs/) | 1.85.0 or later | Use `rustup toolchain install stable`. | +| Tauri system dependencies | Tauri v2 | Follow the [Tauri prerequisites guide](https://tauri.app/start/prerequisites/) for your OS. | +| [Python 3](https://www.python.org/) | Any 3.x | Only needed for optional asset download scripts. | From the repository root: ```bash pnpm install -``` - -Copy `.env.example` to `.env` only when you need local configuration. `.env` is gitignored; never commit real secrets. - -Run the full desktop app: - -```bash pnpm run tauri:dev ``` -Tauri starts the webpack dev server through its `beforeDevCommand`; contributors should use the Tauri scripts rather than launching the frontend shell independently. - -For fast desktop iteration against a built app bundle, use: +Tauri starts the webpack dev server through its `beforeDevCommand`, so use the Tauri scripts for normal desktop development. -```bash -pnpm run tauri:build:fast -``` - -This is the fast iteration mode for validating local Tauri changes outside the dev server: it cleans only the app target for the local development profile, rebuilds the app bundle, and opens it immediately. - -## Useful checks - -Run the checks that match the files you changed before opening a PR. +Copy `.env.example` to `.env` only when you need local configuration. `.env` is gitignored; never commit real secrets. -Frontend: +For fast desktop iteration against a built app bundle: ```bash -pnpm run lint -pnpm run test -pnpm run check:circular +pnpm run tauri:build:fast ``` -Rust/Tauri: - -```bash -pnpm run cargo:check -pnpm run cargo:clippy -pnpm run cargo:test -``` +## Run the right checks -Targeted Rust module tests are available, for example: +Run the checks that match the files you changed. If you cannot run a relevant check locally, explain why in the PR and include any partial verification you performed. -```bash -pnpm run cargo:test:agent_core -pnpm run cargo:test:event_store -pnpm run cargo:test:work_station -``` +| Change area | Recommended checks | +| ----------------------------- | ---------------------------------------------------------------------------------------------------------- | +| Frontend / TypeScript | `pnpm run lint`, `pnpm run test`, `pnpm run check:circular` | +| Rust / Tauri | `pnpm run cargo:check`, `pnpm run cargo:clippy`, `pnpm run cargo:test` | +| Targeted Rust modules | `pnpm run cargo:test:agent_core`, `pnpm run cargo:test:event_store`, or `pnpm run cargo:test:work_station` | +| Chat, session, or UI behavior | E2E tests in `tests/e2e`; see `tests/e2e/README.md` | -Core UI end-to-end tests live in `tests/e2e` and use WebDriverIO with `tauri-webdriver-automation`. Run them after UI changes that can affect chat or session behavior: +Core UI end-to-end tests use WebDriverIO with `tauri-webdriver-automation`: ```bash cargo install tauri-webdriver-automation --locked @@ -87,110 +60,78 @@ pnpm install pnpm test ``` -See `tests/e2e/README.md` for account setup, isolated service runs, targeted specs, and scenario filters. - -If a check cannot be run locally, explain why in the pull request and include any partial verification you performed. - -## Project structure - -- `src/` — React, TypeScript, UI, stores, hooks, and frontend services -- `src-tauri/` — Tauri shell and Rust backend -- `docs/` — living architecture and feature documentation -- `scripts/` — development, setup, maintenance, and build scripts -- `tests/` — repository-level tests and test helpers +## Project map -Documentation is organized by domain under `docs/`. New or substantially changed features should include documentation when the behavior, architecture, or operating model is not obvious from the code. Use lowercase domain folders such as `docs/architecture/`, `docs/shared/`, `docs/workstation/`, and `docs/contributing/`; use `{subject}--MMDD.md` for date-stamped domain docs. +| Path | Purpose | +| ------------ | ----------------------------------------------------------- | +| `src/` | React, TypeScript, UI, stores, hooks, and frontend services | +| `src-tauri/` | Tauri shell and Rust backend | +| `docs/` | Living architecture and feature documentation | +| `scripts/` | Development, setup, maintenance, and build scripts | +| `tests/` | Repository-level tests and test helpers | -## Coding guidelines +Add or update docs when behavior, architecture, setup, or user-visible behavior changes in a way that is not obvious from the code. -Follow the repository rule files in `.cursor/rules/`, especially: +## Coding expectations -- `.cursor/rules/orgii-frontend.mdc` for frontend architecture, React, TypeScript, styling, state, i18n, and UI conventions. -- `.cursor/rules/frontend-backend-alignment.mdc` for contracts between TypeScript and Tauri/Rust. -- `.cursor/rules/rust-resource-lifecycle.mdc` and `.cursor/rules/cargo-cleanup.mdc` for Rust backend work. -- The focused MDC files for terminology, tooltips, session rendering, and layout debugging when touching those areas. +Use the repository rules in `.cursor/rules/` as the source of truth. The most common expectations are: -In general, keep changes focused, use existing shared components and tokens, prefer typed values over hardcoded domain strings, propagate errors instead of silently swallowing them, update all supported locales for user-facing text, and remove dead code immediately. +- Keep changes focused and remove dead code immediately. +- Use existing shared components, hooks, stores, and design tokens. +- Prefer typed constants and enums over hardcoded domain strings. +- Let errors propagate instead of silently returning empty fallback data. +- Update all supported locales when changing user-facing UI text. +- Follow frontend/backend contract rules when a setting, command, or wire type crosses the TypeScript and Rust boundary. -## Tests +For deeper guidance, read: -Add or update tests when changing behavior. Prefer focused tests that cover the changed module or component. For bug fixes, include a regression test when practical. +- `.cursor/rules/orgii-frontend.mdc` +- `.cursor/rules/frontend-backend-alignment.mdc` +- `.cursor/rules/rust-resource-lifecycle.mdc` +- `.cursor/rules/cargo-cleanup.mdc` -## Commit and pull request format +## Commits and pull requests -Commit messages and pull request titles must use scoped Conventional Commits: +Commit messages and PR titles must use scoped Conventional Commits: ```text feat(scope): short imperative summary fix(scope): short imperative summary ``` -Use the type that best matches the change. Common types include `feat`, `fix`, `chore`, `docs`, `style`, `test`, `refactor`, `perf`, `build`, `ci`, and `revert`. The scope should be lowercase kebab-case and should name the affected area, such as `git`, `settings`, `workstation`, `slash-menu`, or `contributing`. +Use lowercase kebab-case scopes such as `git`, `settings`, `workstation`, `slash-menu`, or `contributing`. Common types include `feat`, `fix`, `chore`, `docs`, `style`, `test`, `refactor`, `perf`, `build`, `ci`, and `revert`. -Every commit must include a proper message body unless the change is truly trivial. The body should explain why the change exists, summarize the important behavior or architecture changes, and mention notable verification or migration details. Do not leave commits with only a subject line or only automated hook metadata. +Every non-trivial commit needs a body that explains why the change exists, summarizes important behavior or architecture changes, and mentions notable verification or migration details. -The pre-commit hook appends a tamper-evidence trailer of the form -`Pre-commit hook ran. Total eslint: N, total circular: N` to every commit -it processes. The trailer is intentional: commits without it were created -with `--no-verify`, `HUSKY=0`, or another bypass, and reviewers should -treat such commits with extra scrutiny. Do not strip the trailer when -amending or rewording. - -Examples: +A `commit-msg` hook runs commitlint, and the pre-commit hook appends a tamper-evidence trailer: ```text -feat(git): add remote authentication prompt - -Add an inline authentication prompt for remote operations so push and -pull failures can recover without sending users to a terminal. The -prompt accepts temporary tokens or stores them through the Git helper -when the user opts in. - -fix(settings): preserve Git fetch preference - -Keep the fetch preference in the Git integration settings instead of -resetting it when other network settings are edited. This avoids losing -the user's chosen sync behavior when unrelated fields change. - -chore(contributing): document commit format - -Clarify that commits need a scoped Conventional Commit subject and a -body that explains intent, not just automated hook metadata. +Pre-commit hook ran. Total eslint: N, total circular: N ``` -A `commit-msg` git hook runs [commitlint](https://commitlint.js.org/) with `@commitlint/config-conventional` and the project's `commitlint.config.cjs` on every commit. The hook rejects subjects that are missing a type, exceed 72 characters, end with a period, use uppercase types or scopes, or otherwise fail the rules. Fix the message and commit again rather than bypassing the hook. +Do not remove this trailer, and do not bypass hooks with `--no-verify`, `HUSKY=0`, or similar unless a maintainer explicitly asks you to do so for emergency recovery. If a hook fails, fix the issue and rerun the command normally. + +## Pull request checklist -Pull request descriptions must include a clear summary and a test plan. If checks were not run, explain why and list any partial verification performed. +Before requesting review, make sure the PR has: -Do not skip pre-commit, pre-push, lint-staged, or other repository hooks. Do not use `--no-verify`, `HUSKY=0`, or equivalent bypasses unless a maintainer explicitly asks you to do so for an emergency recovery task. If a hook fails, fix the underlying issue and rerun the command normally. +- A clear title, description, and test plan. +- One focused issue, feature, or fix. +- Relevant checks passing, or a note explaining what could not be run. +- CLA Assistant passing, plus screenshots or docs when the change needs them. -If you use a coding agent, the agent must read and follow this section before creating commits or pull requests. Agent-generated commits and PRs must use the same format, run the same checks, and must not bypass hooks. +Also confirm that no secrets, local configuration, generated build output, or unrelated files are included. ## Contributor License Agreement ORGII requires contributors to sign the repository Contributor License Agreement before a pull request can be merged. The agreement text is in [`docs/contributing/CLA.md`](docs/contributing/CLA.md). -The repository uses GitHub CLA Assistant to collect signatures and report CLA status on pull requests. When you open your first PR, CLA Assistant will comment with a signing link if your GitHub account has not signed the current agreement. +GitHub CLA Assistant will comment with a signing link on your first PR if your GitHub account has not signed the current agreement. -Choose the signing path that matches your contribution: +- Sign as an individual when the contribution is your own work and you are legally allowed to submit it. +- Sign as a company only if you are authorized to bind that organization to the CLA. -- **Individual contributor:** sign as yourself when the contribution is your own work and you are legally allowed to submit it. -- **Corporate contributor:** sign on behalf of your employer or organization only if you are authorized to bind that entity to the CLA. If you are not authorized, sign only as an individual and submit only work you are permitted to contribute individually. - -Maintainers will not merge PRs until the CLA check passes. Corporate signatures may require additional maintainer review if the signing authority is unclear. - -## Pull request checklist - -Before requesting review, confirm that: - -- The PR has a clear title and description. -- The change is scoped to one issue, feature, or fix. -- The CLA Assistant check passes, or you have asked maintainers for help resolving the signature status. -- Relevant lint, test, and cargo commands pass or are documented as not run. -- UI changes include screenshots or screen recordings when useful. -- New UI text has translations for all supported locales. -- Documentation was added or updated when the change affects architecture, setup, or user-visible behavior. -- No secrets, local configuration, generated build output, or unrelated files are included. +Maintainers will not merge PRs until the CLA check passes. ## Security @@ -198,6 +139,8 @@ Do not report security vulnerabilities in public issues. Use the repository's pr Never commit API keys, signing keys, OAuth secrets, personal tokens, private logs, or user data. If you accidentally expose a secret, revoke it immediately and notify maintainers. -## License +## Code of conduct and license + +Be respectful, constructive, and specific. Keep issue and pull request discussions focused on the work. -By contributing, you agree that your contributions are licensed under the repository license: AGPL-3.0-or-later and are submitted under the Contributor License Agreement in `docs/contributing/CLA.md`. See `LICENSE` for the full license text. +By contributing, you agree that your contributions are licensed under AGPL-3.0-or-later and submitted under the Contributor License Agreement in `docs/contributing/CLA.md`. See `LICENSE` for the full license text. diff --git a/README.md b/README.md index 0016e3769..198119275 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,6 @@

ORG-2

Open-source Cursor-style agent IDE
— but built for reviewability, traceability, and creative freedom, not just faster coding.

-

- License - Downloads - Last commit - Commit activity - Discord -

-

- yorgai%2FORG2 | Trendshift -

--- @@ -22,6 +12,10 @@ · Windows MSI · + Linux AppImage + · + Linux DEB + · All latest release assets

@@ -41,9 +35,97 @@ It is not just another AI coding tool; it is an experiment in human/agent organi ORG-II explores a different model: agents as persistent, observable colleagues inside a structured organization. Instead of stateless, hard-to-review AI IDE sessions, it introduces replayable agent execution, cross-session memory, AI blame, and a local-first Rust-based runtime so humans, agents, and teams can collaborate around shared context and aligned goals. -## Key capabilities +## Features + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +### Manage sessions across 10+ apps & CLIs + +Load and manage agent sessions from all your tools in one place. Scan history, inspect subagents, and control each source without switching apps. + + + Manage agent session sources across apps and CLIs in ORG-II +
+ +### Review trajectories, not just PRs + +Form your team and share sessions across devices and teammates. Review the full agent trajectory, not only the resulting diff, and leave comments in context. + + + Manage teammates and trajectory replay permissions in ORG-II +
+ +### Tool calls, now as videos + +Replay the work as it happened. Messages, tool calls, file edits, and command output stay synchronized in one reviewable timeline. + + + Replay an agent session in ORG-II +
+ +### AI blame, not just Git blame + +Do not stop at who changed a line. Trace it back to the agent sessions, tool calls, and decisions that drove the change. + + + Trace code changes back to agent sessions and decisions in ORG-II +
+ +### Stay on track + +See how your time is spent across tasks and agent sessions. A daily activity timeline keeps duration, code changes, and priorities visible. + + + Review time spent across tasks and agent sessions in ORG-II +
+ +### Full dev workspace + +Use the terminal, manage source control, trace Git history, and review pull requests without leaving your agent workspace. + + + Source control, Git history, and code review tools in ORG-II +
+ +### Design Mode + +Inspect live pages in the native WebKit browser. Select an element and send its exact page context straight to the agent for a straightforward fix. + + + Inspect a webpage element with ORG-II Design Mode +
+ +## More capabilities -- Long-running sessions with replayable execution traces for auditing, review, and debugging. - Rust-based agents that work with your existing API keys and agent subscriptions. - GUI, CLI, terminal, Git, browser, LSP, timeline, and database tooling. - Cross-session memory, cross-agent knowledge sharing, and shared workspace state. @@ -53,15 +135,50 @@ ORG-II explores a different model: agents as persistent, observable colleagues i - Org-level alignment surfaces (issues/projects management) for coordinating humans, agents, goals, and accountability (WIP). - Session collaboration and group issue workflows via self-hosted Supabase (WIP). +## Supported CLI agents + +ORG-II includes built-in launch profiles for these coding agents, managed from the desktop TUI. + +

+ Cursor CLI logo Cursor CLI   + Claude Code logo Claude Code   + Codex logo Codex   + Kiro CLI logo Kiro CLI   + GitHub Copilot logo GitHub Copilot   + Kimi Code CLI logo Kimi Code CLI   + OpenCode logo OpenCode   + Aider logo Aider   + Goose logo Goose   + Amp logo Amp   + Cline logo Cline   + Kilo Code logo Kilo Code   + Grok CLI logo Grok CLI   + Devin logo Devin   + Hermes logo Hermes   + OpenClaw logo OpenClaw   + Codebuff logo Codebuff   + Qwen Code logo Qwen Code   + Mimo Code logo Mimo Code   + Antigravity logo Antigravity   + Continue logo Continue   + Droid logo Droid   + Mistral Vibe logo Mistral Vibe   + Autohand logo Autohand   + OMP logo OMP   + Pi logo Pi +

+ ## Download -Current build version: v1.1.3 (2026-06-25) +Current build version: v1.1.24 (2026-07-16) Download the latest ORGII desktop app with one click: - [macOS Apple Silicon](https://github.com/yorgai/ORG2/releases/latest/download/ORG2-latest-mac-apple-silicon.dmg) - [Windows x64 installer](https://github.com/yorgai/ORG2/releases/latest/download/ORG2-latest-windows-x64-setup.exe) - [Windows x64 MSI](https://github.com/yorgai/ORG2/releases/latest/download/ORG2-latest-windows-x64.msi) +- [Linux x64 AppImage](https://github.com/yorgai/ORG2/releases/latest/download/ORG2-latest-linux-x64.AppImage) +- [Linux x64 DEB](https://github.com/yorgai/ORG2/releases/latest/download/ORG2-latest-linux-x64.deb) - [All latest release assets](https://github.com/yorgai/ORG2/releases/latest) The direct download links always resolve through GitHub's latest release pointer. diff --git a/docs/architecture-audit-2026-07-08/source-control-repo-scope.md b/docs/architecture-audit-2026-07-08/source-control-repo-scope.md new file mode 100644 index 000000000..15f4a93f6 --- /dev/null +++ b/docs/architecture-audit-2026-07-08/source-control-repo-scope.md @@ -0,0 +1,36 @@ +# Architecture Audit — Source Control Repo Scope + +**Date:** 2026-07-08 +**Scope:** Source Control issue/PR workstation atoms, sidebar/main-pane readers, issue detail tabs, ADE context collection. + +## Layers Covered + +| Layer | Verdict | Notes | +| ------------------------------- | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 Compilation correctness | pending verification | `npm run typecheck` and targeted lint are run after this report is staged. | +| 2 Dead code / structural dedupe | pass with fix | Swept legacy global atom reads. Production readers now use `atomFamily`; legacy exports remain only as default-scope compatibility aliases. | +| 3 Naming consistency | pass | New `workstationRepoScopeKey` names the repo/path fallback explicitly. | +| 4 Semantic overloading | pass | "scope" is limited to workstation repo state, not UI filter scope. | +| 5 Default branch analysis | pass with note | Fallback is `repo:` -> `path:` -> `default`; API calls keep their existing `"default"` repo id fallback separately. | +| 6 Cross-domain leakage | pass | Repo-scoped state remains in workstation code-editor atoms; ADE collector only reads the active workspace scope. | +| 7 New developer confusion | pass with fix | Default-scope legacy exports now alias the corresponding family atom instead of being independent atoms. | +| 8 Wire protocol | not applicable | No serialized payload shape changed; repo id/path request arguments are preserved. | +| 9 Init parity | pass | Sidebar, main pane, issue detail tab, Manage Issues handoff, and ADE collector all derive a scope key before reading/writing issue/PR state. | +| 10 Resolver symmetry | pass with fix | Issue and PR state families use the same repo id/path fallback chain, and each family instance receives its own initial object. | + +## Sweeps + +| Sweep | Result | +| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Legacy global atom reads | No production reads remain outside compatibility exports and docs/test notes. | +| Scope key propagation | `useWorkstationPr`, `useWorkstationIssues`, Source Control sidebar/main pane, issue detail tab, Manage Issues handoff, and ADE context collector all derive scoped atoms. | +| API repo id fallback | Preserved existing `repoId ?? "default"` for backend calls while state scoping can still fall back to repo path. | + +## Fixes Landed From Audit + +- Converted legacy PR/issue callback/list exports to default-scope family aliases for consistent compatibility behavior. +- Cloned family initial objects/arrays per scope to avoid shared initial references. + +## Residual Notes + +- `workstationSelectedPrAtom` still has a TODO referencing future atom-family migration, but it is a separate selected-PR detail surface and not part of this issue/PR list-state split. diff --git a/docs/architecture-audit-2026-07-11/AgentToolsStrictSchemaNullable.md b/docs/architecture-audit-2026-07-11/AgentToolsStrictSchemaNullable.md new file mode 100644 index 000000000..ce61dffe1 --- /dev/null +++ b/docs/architecture-audit-2026-07-11/AgentToolsStrictSchemaNullable.md @@ -0,0 +1,150 @@ +# Architecture Audit — agent tools strict-schema optional fields and todo snapshot deduplication + +**Date:** 2026-07-11 +**Auditor:** orgii session +**Skill:** `.orgii/skills/architecture-audit/SKILL.md` +**Scope (changed files):** + +- `src-tauri/crates/agent-core/src/core/providers/responses_common/types.rs` +- `src-tauri/crates/agent-core/src/core/tools/registry.rs` +- `src-tauri/crates/agent-core/src/core/tools/impls/coding/code_search.rs` +- `src-tauri/crates/agent-core/src/core/tools/impls/coding/manage_todo.rs` +- `src-tauri/crates/agent-core/src/core/tools/tests/registry_tests.rs` +- `src-tauri/crates/agent-core/src/core/tools/tests/search_tool_tests.rs` +- `src/engines/ChatPanel/ChatHistory/chatItemPipeline/pipeline.ts` +- `src/engines/ChatPanel/ChatHistory/chatItemPipeline/__tests__/pipeline.test.ts` + +## What the change does (one theme) + +All eight files implement one strict-schema handling path plus its todo UI +projection: preserve optional tool arguments when the OpenAI Responses API uses +`strict: true`, then render only the latest snapshot in a consecutive run of +todo updates. + +The Responses strict contract requires every property to be listed in `required` +and to appear in the model's output. Previously, source-schema _optional_ fields +either broke strict validation or were dropped. The change threads one invariant +end-to-end: + +1. **Outbound schema (wire, `types.rs`):** `enforce_strict_schema` now, for every + property **not** in the source `required` set, calls the new + `make_schema_nullable` to rewrite it as `anyOf: [, {type: null}]`, + then marks all properties required. Optional → "required but nullable". +2. **Inbound params (`registry.rs`):** `ToolRegistry::execute` now runs the new + `strip_optional_null_placeholders` over the model's arguments before invoking + the tool. For properties that were originally optional and are **not** + natively nullable, a literal `null` is deleted, restoring the "field omitted" + shape the tools expect. Recurses into nested objects and array items. +3. **Tool-level validation (`code_search.rs`, `manage_todo.rs`):** nullable + optional search scope is treated as absent; nullable todo update fields mean + "leave unchanged". Empty titles, malformed strings, and invalid `blockedBy` + arrays are rejected instead of silently erasing or weakening a patch. +4. **Frontend projection (`pipeline.ts`):** consecutive `manage_todo` events are + complete snapshots, so only the latest card is retained. A real intervening + activity remains a history boundary. +5. **Tests:** cover schema nullability, registry cleanup, search scope fallback, + todo update parsing, status-count reconciliation, and snapshot boundaries. + +## Layers covered + +| Layer | Covered | Verdict | +| --------------------------------- | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 — Compilation correctness | yes | Deferred to quality gate (`cargo check` / `cargo test -p agent-core`). | +| 2 — Dead code & dedup | yes | Removed the now test-only optional-content helper; one cross-module schema helper duplication remains intentionally local. | +| 3 — Naming consistency | yes | Clean. | +| 4 — Semantic overloading | yes | `required`/`nullable`/`optional` used consistently. | +| 5 — Default branch analysis | yes | `_ => false` branches are correct. | +| 8 — Wire protocol & serialization | yes (**primary**) | Round-trip parity holds; see analysis. | +| 9 — Init/entry-point parity | yes | Production model-driven calls funnel through `ToolRegistry::execute`; direct debug/test calls do not consume provider placeholders. | +| 6, 7, 10 | partially / n/a | Layer 6 (cross-domain leakage) n/a — code is generic schema plumbing; Layer 7 (new-dev clarity) good — doc comments explain intent; Layer 10 (resolver symmetry) n/a — no multi-field resolver. | + +## Layer 8 — Wire Protocol & Serialization (primary) + +This is exactly the layer this change lives in: it alters the JSON schema sent to +the provider and the JSON params received back. + +**Round-trip parity invariant (verified by reading both sides):** + +- The outbound transform (`make_schema_nullable`) makes a field nullable **iff** + it is not in the source `required` set. +- The inbound strip (`strip_optional_null_placeholders`) deletes a `null` **iff** + the field is not in the source `required` set **and** the property schema does + not natively accept null. +- Both sides key off the **same source `required` set**, so the set of fields + that can carry a `null` placeholder outbound is exactly the set stripped + inbound. This symmetry is the correctness core — documented here so a future + edit to one side without the other is caught. + +**Critical detail — the strip reads the pre-strict schema.** `ToolRegistry::execute` +passes `&tool.parameters()` (the original, non-strict schema) to the strip, not +the `enforce_strict_schema`-mutated copy. That is correct: `enforce_strict_schema` +is applied on a _clone_ at the provider boundary (`converter.rs:266`), never +mutating `tool.parameters()`. So `originally_required` inside the strip genuinely +reflects the source optionality. **Fragility note:** if someone ever makes +`enforce_strict_schema` mutate the registered schema in place, the strip's +`originally_required` would become the strict (all-required) set and it would stop +stripping. The call site continues to pass a freshly generated source schema. + +**Explicitly-nullable fields are preserved:** `strip_optional_null_placeholders` +uses `schema_accepts_null` so a field whose source schema is `anyOf:[T, null]` +keeps its `null` value (the test `strict_schema_null_placeholders_restore_optional_omissions` +asserts `explicit_null` survives while `repo_paths` is dropped). Good — "omitted" +and "intentionally null" stay distinct. + +**Provider-agnostic strip:** the strip runs in `ToolRegistry::execute` for all +providers, but only strict Responses/Codex providers generate the null +placeholders. For non-strict providers there are no such placeholders, so the +strip is a no-op in practice (and harmless if a model ever emitted a stray null +for an optional non-nullable field). Acceptable. + +## Layer 9 — Entry-Point Parity + +Tool execution entry points: + +| Entry point | Applies `strip_optional_null_placeholders`? | Verdict | +| ------------------------------------------------------------------------ | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| `ToolRegistry::execute` (production) | yes | correct | +| `ToolRegistry::execute_action` → `self.execute` | yes (delegates) | correct | +| `state/commands/session/debug/org_runtime.rs` direct `tool.execute(...)` | no (bypasses registry) | acceptable — debug endpoint constructs params in-code, not from a model, so no null placeholders arise. Flagged for awareness. | +| test helpers calling `tool.execute(...)` directly | no | expected — tests exercise tool logic directly. | + +No production path constructs tool params from a model response and bypasses the +registry strip. Parity holds for the model-driven path. + +## Layer 2 — Dead Code & Duplication + +- `make_schema_nullable` (types.rs) contains an `already_nullable` check that is + logically identical to `schema_accepts_null` (registry.rs): both answer "does + this schema permit `null`?" by inspecting `type` (string/array) and + `anyOf`/`oneOf` variants. + **Verdict: keep with reason (low-priority dedup candidate).** The two live in + different modules (`providers::responses_common` vs `tools`) with no existing + shared util between them; extracting a helper would create a new cross-module + dependency for ~10 lines. Note it so the next pass can promote it if a third + copy appears. +- `optional_string_param` and `optional_index_array` centralize nullable update + parsing. The old `sanitize_optional_todo_content` helper was live only through + its own test after the extraction, so it was deleted. + +## Layer 5 — Default Branch Analysis + +- `schema_accepts_null` / `make_schema_nullable`: `match kind { String, Array, _ => false }` + — the `_` correctly covers non-type-descriptor JSON values (a schema `type` + that is neither a string nor an array of strings cannot declare null). Safe. +- `optional_string_param`: absent and `null` explicitly mean "leave untouched"; + strings are returned for field-specific validation; every other JSON type is + rejected. Correct and intentional. + +## Summary + +- **1 coherent strict-schema/todo feature** across 8 files; correctly split into + outbound schema, inbound cleanup, tool validation, and frontend projection + layers. +- **Round-trip parity verified** — outbound nullable-set and inbound strip-set are + keyed off the same source `required` set. +- **0 open fix candidates** after the focused cleanup. +- **1 keep-with-reason flag:** + 1. `make_schema_nullable`'s `already_nullable` vs `schema_accepts_null` + duplication — low-priority dedup candidate, cross-module. +- **1 parity note:** debug/test direct tool calls bypass the registry strip; + acceptable because they do not consume provider-generated params. diff --git a/docs/architecture-audit-2026-07-11/WorktreeResolvePrBase.md b/docs/architecture-audit-2026-07-11/WorktreeResolvePrBase.md new file mode 100644 index 000000000..4ec7cebc9 --- /dev/null +++ b/docs/architecture-audit-2026-07-11/WorktreeResolvePrBase.md @@ -0,0 +1,118 @@ +# Architecture Audit — `worktree_resolve_pr_base` (PR → git-resolvable base ref) + +**Date:** 2026-07-11 +**Scope:** New backend command `git::pr_base::worktree_resolve_pr_base` + its +cross-layer wire contract to the frontend (`resolvePrWorktreeBase`, +`WorktreeLaunchSource.resolvedBaseRef`, `getWorktreeFields`). +**Auditor:** worktree PR-base resolution session. + +This change adds a Rust command + TS wiring. Per the routing rule, the layers +that the change clearly touches are audited; layers with no surface are marked +**skipped** with a one-line reason. + +--- + +## Layer 1 — Compilation Correctness + +- `cargo test -p git pr_base` → compiles, **13/13 tests pass**. +- `cargo check -p org2` (full app crate, exercises `generate_handler!` registration + of the new command) → **Finished, no errors**. +- `pnpm typecheck` (`tsc --noEmit`) → **exit 0**. +- No new clippy-visible patterns introduced (subprocess + thread drain mirror the + existing `bundle.rs` idiom). **Pass.** + +## Layer 2 — Dead Code & Structural Deduplication + +- Traced from the business entry point: FE `WorktreeSourceModal.handleConfirm` + → `resolvePrWorktreeBase` (`invoke("worktree_resolve_pr_base")`) → Rust + `worktree_resolve_pr_base` → `resolve_pr_base` → `resolve_pr_base_with` (pure) + → real git runner. Every new symbol is on a live path. +- `resolve_pr_base_with` is generic over a runner so the pure logic is shared by + both the real command **and** the unit tests — no parallel test-only reimplementation. +- Did **not** duplicate `git worktree` / fetch logic that already exists: the + resolver only fetches + rev-parses; worktree creation stays in the existing + `create_session_worktree` path (base ref forwarded via the existing `branch` + field). **Pass.** + +## Layer 3 — Naming Consistency + +- Command name `worktree_resolve_pr_base` matches the `git::*` snake_case Tauri + convention (`get_local_head_sha`, `merge_cloud_ref`); FE wrapper + `resolvePrWorktreeBase` matches the FE camelCase convention. +- `PrBaseResolution` / `PrBaseSource` serialize camelCase (`baseRef`, `headSha`, + `branchNameOverride`, `compareBaseRef`, `source: "branch"|"pullRef"`) and the TS + `interface PrBaseResolution` mirrors it field-for-field. **Pass.** + +## Layer 4 — Semantic Overloading + +- `branch`: the launch payload field `SessionLaunchParams.branch` already means + "isolate base commit-ish" for worktree launches (documented in + `getWorktreeFields`). This change keeps that meaning — `resolvedBaseRef` (a SHA) + is fed through the same field. No new overload. +- `baseBranch` vs `baseRef` vs `resolvedBaseRef` vs `compareBaseRef` are + deliberately distinct: `baseBranch` = human label / PR head branch name; + `resolvedBaseRef`/`baseRef` = concrete fetched head SHA (git-usable); + `compareBaseRef` = the diff-against ref (`refs/remotes//`). Each + documented at its definition. **Pass** (checked to avoid re-overloading "base"). + +## Layer 5 — Default Branch Analysis + +- `normalize_remote`: `None`/blank → `origin`. Correct for all callers — the FE + only ever lists PRs from the `origin` GitHub remote, so `origin` is the right + default; an explicit remote is still honored. +- `resolve_pr_base_with` fallback branch: branch-fetch failure only falls through + to `refs/pull//head` when `is_missing_remote_ref_error` matches; **any other + failure (auth/network) surfaces** rather than silently hitting the fallback + (test `surfaces_non_missing_ref_fetch_failure_without_fallback`). This is the + key default-safety property. **Pass.** + +## Layer 8 — Wire Protocol & Serialization Audit + +- Inspected the actual serialized contract, not just the structs: + - **Rust → FE (result):** `#[serde(rename_all = "camelCase")]` on + `PrBaseResolution` + `PrBaseSource`. Enum serializes as `"branch"` / `"pullRef"` + (unit variants → plain strings), matching the TS `type PrBaseSource = +"branch" | "pullRef"`. `Option` → `string | null` (matches TS). + - **FE → Rust (args):** command uses `rename_all = "camelCase"`, so FE passes + `repoPath`, `prNumber`, `remote`, `headBranch`, `baseBranch`. Optional args are + sent as `?? null`, which Tauri maps to `Option::None`. Verified against the + `#[tauri::command(rename_all = "camelCase")]` signature. +- No schema generator involved (no `schemars`); payload is a small fixed struct — + no bloat risk. **Pass.** + +## Layer 9 — Init Parity Across Entry Points + +- Single production entry point (`worktree_resolve_pr_base` Tauri command); the + unit tests drive the same pure core (`resolve_pr_base_with`) through a mock + runner, so test and production share identical branch/fallback logic. The only + step the test path omits is real subprocess execution (`run_git_capture_with_timeout`), + which is I/O, not business logic — an intentional, documented seam. **Pass.** + +## Layer 10 — Resolver Symmetry + +- `resolve_pr_base_with` resolves one primary output (`base_ref` = head SHA) via a + two-tier source chain: (1) `fetch `, (2) fallback + `fetch refs/pull//head`. Both tiers converge on the _same_ + `rev_parse_fetch_head` reader — symmetric extraction, no field skips a source. +- `branch_name_override` is populated on both tiers when a head branch is known + (test asserts fork PRs still surface the head branch as a label). `compare_base_ref` + is derived identically regardless of which fetch tier won. No asymmetry. **Pass.** + +## Layers skipped (no surface) + +- **Layer 6 (cross-domain leakage):** the new module is a self-contained git leaf; + no shared/core module gained a PR-specific field. +- **Layer 7 (new-dev confusion):** covered implicitly by Layer 3/4 naming review; + all new public items carry doc comments explaining intent + the orca alignment. + +--- + +## Summary + +- **All audited layers pass** (1,2,3,4,5,8,9,10); 6 & 7 skipped with reason. +- Key safety properties verified by tests: fork fallback only on missing-ref, + auth/network errors surface, empty rev-parse errors, blank head branch → pull ref. +- Wire contract confirmed symmetric on both directions (camelCase, enum strings, + `Option`↔`null`). +- No dead code, no duplication of existing worktree/fetch logic, no new semantic + overload of `branch` / `base`. diff --git a/docs/architecture-audit-2026-07-12/AgentOrgRecovery.md b/docs/architecture-audit-2026-07-12/AgentOrgRecovery.md new file mode 100644 index 000000000..5664504e8 --- /dev/null +++ b/docs/architecture-audit-2026-07-12/AgentOrgRecovery.md @@ -0,0 +1,444 @@ +# Agent Org Recovery 与一致性加固:最终代码审计报告 + +> 审计日期:2026-07-13 +> 分支:`fix/issue-272-agent-org-recovery-invariants` +> 基线:`develop` / `ea8516092` +> 范围:GitHub issue #272、相邻的 Agent Org 状态一致性、恢复、调度、任务原子性、CLI 能力边界和对应 UI 入口 +> 结论:依赖确认门、显式 Coordinator 指派、Planner 审批、生命周期收尾和 Pause/Resume 已通过连续真人 dev 验收;最终提交仍以本次全量检查与 Husky hooks 的实际结果为准。 + +## 一句话结论 + +这次把 Agent Org 从“看见像卡住了就尝试叫人、甚至按时间抢任务”,改成了“先读数据库做无副作用诊断,再按持久预算、真实任务资格和 run 状态执行恢复”。Run、Session、Task、Inbox/Wake 四种状态不再互相冒充,重复 wake 会合并,terminal/paused run 不会被后台门铃复活,任务完成与 run 收尾也不会再互相穿透。真人运行暴露出的“蓝色运行态反复闪、成员成果没有可靠交给下游、coordinator 晚一拍仍认为 blocked、所有人完成后 run 不自动收尾”也已经按四批追加修复;随后又根据真实日志修掉了更上游的根因:普通 Coordinator 消息不再被误判成用户接管 worker。第二次 Breaking Bad 真人运行继续暴露出两个新根因:Coordinator 把有先后关系的 Implement/Review/Test 当成并行任务,以及 duplicate Wake 虽被 scheduler 合并、却在数据库留下永远 queued 的新 intent。第三次 The Boys 真人运行证明底层依赖调度正确,但 Coordinator 的文字说明写着“等写作和检查完成”,结构化 `dependency_task_ids` 却只填写 Planner;本轮因此新增无副作用的依赖确认门,漏列当前 open task 时先返回 guidance,只有补齐依赖或明确确认并行后才允许落库。 + +审计过程中还修复了相邻尾部问题:顶层 `org2` 编译路径错误、主动 shutdown 成员可能被 watchdog 复活、失败恢复曾直接唤醒 peer、损坏的 recovery deadline 会永久压住重试、启动恢复只扫描 500 个 run、CLI 保存错误没带 member_id、重复 Wake intent 不收口、旧 in-flight intent 不自愈、已完成 Run 重启后仍被通用策略暂停,以及 debug restart 与生产启动步骤漂移。最终设计进一步删除了失败后的 Worker 自动领取和自动 Wake,统一交回 Coordinator 明确指派。 + +## 大白话:现在这套设计是什么 + +| 代码名词 | 大白话 | 数据库里的真相 | +| --------------- | ---------------------------------- | ---------------------------------------------- | +| `AgentOrgRun` | 一次团队项目 | `agent_org_runs` | +| `Session` | 某个成员当前是否在干活 | `agent_sessions` / 历史 CLI 的 `code_sessions` | +| `Task` | 看板工单,记录归属、依赖和完成状态 | `agent_org_tasks` | +| `AgentInbox` | 不会因为进程重启而丢失的信箱 | `agent_inbox` | +| Wake | 门铃,只负责让成员起来读信/接活 | scheduler 中带幂等 key 的 turn | +| Watchdog | 每分钟巡查的保安 | 纯分析 + 恢复执行器 | +| Recovery budget | 保安的持久重试登记簿 | `agent_org_recovery_attempts` | + +```mermaid +flowchart TD + DB["持久化快照
Run + Session + Task + Inbox + Budget"] + A["Recovery Analyzer
只读取,不写库、不 wake"] + P["Recovery Plan
同一轮可 wake、repair、reconcile"] + E["Recovery Executor
动作前重新检查 Run"] + W["Wake Dispatcher
确定性 key 合并重复门铃"] + C["Coordinator Notice
稳定指纹 + 1/5/15 分钟退避"] + F["Run Reconciler
writer lock + Immediate transaction"] + N["No-op
暂停、终态、重复或工作已消失"] + + DB --> A --> P --> E + E --> W + E --> C + E --> F + W -->|"Run 仍 Running 且有真实输入"| DB + W -->|"Paused / terminal / coalesced / no work"| N +``` + +核心原则是: + +1. `Idle session` 只表示员工此刻没执行 turn,不等于项目结束。 +2. `Running run` 只表示项目允许继续,不等于每个成员都健康。 +3. `Pending task` 不等于任何人都能接;依赖、eligibility、现有 owner 工作量都要满足。 +4. `Unread inbox` 是持久事实;wake 只是门铃,排队失败也不能把信当成已送达。 +5. 任何 task mutation 和 run finality 都要经过同一 writer lock,不能出现 terminal run 下面又长出 open task。 + +## 真人运行诊断后的四批追加修复 + +这四批不是换一套 Agent Org,也不是又加了两个 AI。它们是在现有 coordinator、member、task、inbox 之下补齐“门铃、交接单、完工回执、项目验收”四个机械环节。 + +```mermaid +flowchart LR + A["第 1 批:门铃防抖
Wake 不再自我循环"] + B["第 2 批:成果交接单
TaskOutput 持久保存"] + C["第 3 批:完工回执
TaskCompleted 通知 coordinator"] + D["第 4 批:项目验收
Finality 自动且原子"] + + A --> B --> C --> D +``` + +### 第 1 批:切断 WakeNoop 自我循环 + +真人测试时的“蓝色运行态闪一下、停一下、又闪一下”,根因不是模型在反复思考,而是后台门铃在一个特殊窗口里自我循环:用户正在直接和某个 member 对话时,`intervention` 会故意暂停它的组织信箱读取;旧 race guard 又看到信仍未读,于是再次 wake;这个空 turn 结束后仍未读,于是继续 wake。 + +修复后: + +- `wake_one_member` 在排 scheduler 前检查 `AgentMemberInterventionStore`;直接用户对话期间返回 `DeferredIntervention`。 +- turn 结束后的 unread race guard 同时要求:run 仍为 Running、没有 intervention、确实有未读信。 +- 信仍保存在 `agent_inbox`,退出直接对话后再由正常路径读取,不会丢。 + +```mermaid +flowchart TD + U["用户正在直接和 Member 对话"] --> I["Intervention active"] + I --> W["后台发现未读信,申请 Wake"] + W --> D["DeferredIntervention"] + D --> K["信保留未读;不排空 turn;界面不闪"] + K --> R["Return to work 后正常 Wake + Drain"] +``` + +### 实机日志复核:把 Coordinator intervention 的源头一起修掉 + +后来复查“龙之家族概括文”那次真实运行,确认闪烁时用户并没有继续给 Coordinator 发消息。准确时间线是:更早的一条普通 Coordinator 指令错误建立了 3 分钟 `intervention`;worker 随后给 Coordinator 写入未读信;旧系统一边禁止 intervention 中的 Coordinator 读信,一边又因未读信连续创建了 270 个顺序 Wake intent。也就是说,Wake 循环是后果,普通 Coordinator 消息被误分类才是更上游的根因。 + +最终规则现在是: + +| 用户动作 | 系统含义 | 是否建立 intervention | +| ------------------------------------------------------ | -------------------------------- | ----------------------------: | +| 在 root/Coordinator 对话框正常下指令 | 正常指挥整个 Agent Org | 否 | +| 切到 Planner/Implementer/Reviewer 等 worker 后直接聊天 | 用户临时接管这个 worker 的下一轮 | 是 | +| 在 Group Chat 发消息或 @成员 | 组织内正常投递 | 否,并清除目标旧 intervention | +| 点击 Return to work | 把 worker 交还给组织调度 | 清除 | + +```mermaid +flowchart TD + U["用户提交消息"] --> B["Backend 读取 session 的真实 member_id"] + B --> C{"是 coordinator 吗?"} + C -->|"是"| N["普通组织指令;不建立 intervention"] + C -->|"否,是 worker"| I["建立 worker intervention"] + N --> D["Coordinator turn 可正常 drain worker inbox"] + I --> P["暂缓后台 drain,直到 Return to work 或 TTL"] +``` + +实现上不是只改一个 UI 判断: + +- 前端 direct-submit 和 queued-dispatch 只负责报告“这是用户直接输入”; +- Backend 用持久 session 的 canonical `member_id` 做唯一分类,不能靠当前页面标题猜; +- 通用 `agent_send_message` 不再从“有文本”反推 intervention,避免计划续跑或内部 continuation 被误判为用户接管; +- Rust adapter 中重复写 intervention 的逻辑已删除,避免多层重复建立同一状态; +- store 层永久禁止新建 Coordinator intervention;旧版本留下的 Coordinator intervention 在读取时自动清除,升级后不会继续挡信; +- Wake 前置 gate 和 lifecycle race guard 仍保留,负责防住真正的 worker intervention,不再拿它们代替源头修复。 + +### 第 2 批:给跨 Session 的成果一张持久交接单 + +以前 task 只有 `pending / in_progress / completed`,只知道“做完了”,没有可靠保存“做出了什么”。Reviewer 或 implementer 不能安全读取另一个 session 的聊天历史,所以 coordinator 可能看到上游 completed,却仍然不知道下游应该拿什么继续。 + +新增 `TaskOutput`: + +| 字段 | 大白话 | +| ----------------------- | ---------------------------------------- | +| `summary` | 一两句话的成果摘要 | +| `content` | 可直接交给下游的正文(最多 20,000 字符) | +| `artifact_ids` | 大文件或产物的持久引用 | +| `produced_by_member_id` | 谁做的 | +| `produced_at` | 什么时候做的,必须是 RFC3339 时间 | + +有下游依赖的上游任务,如果没有 `output`,系统不会接受 `completed`,而是返回明确 guidance。上游一旦合法完成,`TaskAssigned.dependency_outputs` 会把成果正文或 artifact 引用直接放进下游成员的真实 inbox turn;下游不需要猜 session id,也不需要翻别人的聊天记录。 + +```mermaid +flowchart LR + P["Producer Session"] -->|"task_update completed + output"| T["Task 持久状态"] + T -->|"dependency_outputs"| I["下游 Inbox"] + I -->|"Wake + Drain"| R["Reviewer / Implementer Session"] +``` + +### 第 3 批:把“成员空闲”和“任务完成”分成两封不同的回执 + +`MemberIdle` 只表示“这个人这一轮结束了”,不能证明某张任务单已经完成。新增 system-only 的 `TaskCompleted`,明确告诉 coordinator:哪张 task、谁完成、输出摘要是什么、还剩多少 open task。 + +- 只有 task 当前 owner 能把它设为 completed;coordinator 不能替 member 猜完成。 +- completed task 的状态是单调的,store 层禁止退回 pending/in_progress;修改需求必须新建 follow-up task。 +- 完成事务产生的 before/after `TaskMutationOutcome` 决定是否发一次回执,避免并发更新重复通知。 +- coordinator 收到回执后重新读持久 task board,再决定继续派工还是给用户最终答复。 + +### 第 4 批:把 run 的结束改成真正的“项目验收” + +过去所有成员都回到 Idle 后,run 可能仍保持 Running;用户 pause/resume 会额外叫醒 coordinator,于是看起来像“暂停再恢复才突然知道做完了”。现在每个 Agent Org turn 结束都会尝试 `reconcile_run_finality`,但只有下面条件在同一个 SQLite `IMMEDIATE` transaction 内仍同时成立,才会写 Completed: + +1. task board 非空,且全部 task completed; +2. root coordinator 和所有 worker 都已静止; +3. 没有未读 inbox; +4. 没有 active intervention; +5. 没有 `optimistic / queued / running` 的未终结 turn intent; +6. coordinator 的最后一个完整 turn 晚于最后一次 task 更新,证明它已经看过最新事实并有机会给用户最终答复。 + +`reconcile_run_finality` 和所有 task mutation 共用 writer lock,所以“刚判定完成,另一个 turn 又创建 open task”的结果不可能写进数据库。 + +前端新增 `runPhase`,用户不必再靠蓝色动画猜系统在做什么: + +| Phase | 界面含义 | +| ----------------------------------------------------- | ---------------------------------------------------------- | +| `coordinating` | coordinator 正在拆解/组织 | +| `dispatching` | 有持久消息正在派送 | +| `members_working` | 至少一个成员真正执行中或等待用户/额度 | +| `waiting` | 有 open task,但当前无人执行;恢复系统会继续检查 | +| `finalizing` | task 全部完成,正在等待最后消息排空和 coordinator 最终答复 | +| `paused / completed / failed / cancelled / abandoned` | run 的持久状态 | + +### 四批追加审计发现并修正的尾部问题 + +| 发现 | 为什么危险 | 最终修正 | +| ------------------------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------ | +| 旧 history 测试会把 completed task 重新打开 | 测试在鼓励已经禁止的非法生命周期 | 改成合法的 subject update 后再 release,仍覆盖 history 顺序 | +| Finality 最初只检查 `queued` intent | `optimistic` 或状态同步窗口里的 `running` intent 可能被漏掉 | 改为任何未终结 intent 都阻止收尾,并覆盖三种状态 | +| 新输出上限使用 UTF-8 byte 长度却写“字符” | 中文会被按 3 个 byte 计算,实际限制比文案小很多 | 所有新 output/dependency summary/content 限制统一按 Unicode 字符数 | +| `produced_at` 只反序列化、不验证时间 | 直接 store 调用可留下无法审计的伪时间 | store 层强制 RFC3339,并加 malformed metadata 回归 | +| UI 活跃数漏掉 `waiting_for_funds` | 后端认为成员工作中,界面却显示 active=0 | 前端 phase 和 active badge 使用同一状态集合 | + +## Breaking Bad 真人运行后的顺序派工与 Wake intent 修复 + +### 1. 任务不再因“漏写 blocked_by”静默抢跑 + +旧 `task_create` 把 `blocked_by=[]` 当成默认值。Coordinator 只要忘记填这个数组,Reviewer 和 Tester 就会立刻收到 `TaskAssigned`,即使任务描述明明写着“在概述和审核结果基础上”。现在工具协议强制每次创建任务明确选择: + +| 新字段 | 含义 | 何时派工 | +| -------------------------------------------------------------- | ---------------------------- | --------------------------- | +| `dispatch_policy="immediate"` | 当前信息已经足够、可独立开工 | 创建后立即派工 | +| `dispatch_policy="after_dependencies"` + `dependency_task_ids` | 必须消费上游持久成果 | 所有上游 completed 后才派工 | + +`blocked_by` 仍是数据库里的底层表示,但不再暴露为 `task_create` 的易漏默认项。新协议还拒绝空依赖、未知 task id、self-cycle,以及 `immediate` 偷带 dependency id。最初使用 Rust tagged enum 会产生部分 LLM provider 不支持的 `oneOf`;审计测试捕获后,wire schema 改成扁平字符串 + 数组,在工具边界解析为 typed `TaskDispatchPolicy`。 + +```mermaid +flowchart LR + I["Implementer
immediate"] -->|"completed + TaskOutput"| R["Reviewer
after_dependencies"] + R -->|"completed + reviewed output"| T["Tester
after_dependencies"] + T -->|"completed"| C["Coordinator final"] +``` + +三阶段回归测试证明:初始只有 Implementer 收到任务;Implementer 完成后 Reviewer 才收到且带上游 output;Reviewer 完成后 Tester 才收到且带审核 output。 + +### 2. Scheduler 负责把每个 enqueue 决定写成终态 + +旧路径会先写一条 `queued` intent,再调用 scheduler。scheduler 发现相同 `client_message_id` 时正确返回 duplicate,但新 intent id 没人更新,于是它永远停在 queued,阻止 `reconcile_run_finality`。现在新增两个精确终态: + +- `coalesced`:请求与已排队/运行的同一逻辑 turn 合并,本 intent 从未执行; +- `rejected`:queue full/closed,本 intent 从未被接受。 + +更新动作放进 Scheduler 本身,而不是要求 message、Wingman 或未来入口分别善后。`coalesced/rejected/stale` 都是 pre-durable terminal,不创建聊天 round,也不阻塞 Run 收尾。20 个并发相同 Wake 仍是 1 个 accepted、19 个 coalesced,但现在数据库 20 条 intent 全都有正确归宿。 + +### 3. 升级后自动修旧数据,启动时先收尾再暂停 + +进程重启后,内存 scheduler 已消失,因此旧 `optimistic/queued` 必然不可能再执行,启动时统一转为 `stale`;旧 `running` 转为 `failed`。实机数据库确认 Breaking Bad 的坏 intent `a7e69850-3f29-4e1c-a439-f35d1f53b659` 已从 `queued` 变为 `stale`。 + +启动顺序现在是:intent 自愈 → 用 durable terminal marker 修正已结束 session → 其余 in-flight session abandoned → task failure disposition → 清 intervention → 原子完成所有 task 已 resolved 的 Run → 只暂停仍需继续的 Run。这样真正未完成的项目仍可恢复,而已经完成、只是被假 queued 卡住的项目不再要求用户手动 Resume 才收尾。debug `simulate-app-restart` 与生产入口使用相同七步顺序,并通过 pause/resume rendered E2E。 + +## 实际改了什么 + +### 1. Watchdog 从“边看边改”改为“先分析、后执行” + +- `inspect_stalled_run` 只读取 run、tasks、sessions、inbox 和 budget,生成可组合计划。 +- 一轮可以同时唤醒 Alice、让 Bob 接 ready task、把坏任务交给 coordinator,并尝试 reconcile。 +- 所有 `SessionStatus` 都被显式分类;没有用 `_ => false` 把未来状态静默吞掉。 +- 保留已决定的 E3 限制:任一 worker 处于 `Running`、`WaitingForUser` 或 `WaitingForFunds` 时,不做 peer 自动恢复;只允许发 stale/unsupported 的观察性 coordinator notice。 +- `Pending` 有 2 分钟 materialization grace;`Paused` 不自动 wake;Missing、Archived、历史 CLI transport 会升级给 coordinator。 + +### 2. Wake 真正幂等,并把 Running 状态放回正确时刻 + +- Agent Org wake 使用 `agent-org-wake:{run_id}:{member_id}`。 +- 20 个并发相同 wake 只接受一个 turn,其他返回 coalesced。 +- enqueue 前不再把 session 写成 Running;scheduler 真正执行 turn 时才更新。 +- 对 Agent Org wake,run 复核和 session→Running 在同一 writer lock + SQLite `IMMEDIATE` transaction 中完成。 +- wake 排队后 run 若变为 Paused/terminal,执行时直接 no-op,不 drain inbox,不调用 provider。 +- drain 后没有任何真实输入时返回空结果,不制造空 nudge,也不花一次 provider call。 + +### 3. Recovery budget 持久化 + +- 新增 `agent_org_recovery_attempts`,按 `(run, action_kind, target)` 保存指纹、次数和 UTC deadline。 +- 退避为 1/5/15 分钟,重启后仍然有效。 +- wake 只有真正 Enqueued 才消耗 member rewake attempt;coalesced 或 enqueue failure 不消耗。 +- member 成功 turn 后清除自己的预算;repair reason 指纹改变时 coordinator notice 自动获得新预算。 +- 非 Running run 的预算定期清理。 +- 损坏的 `next_allowed_at` 现在 fail-open 并告警;不会因为坏时间戳永久压住恢复,下一次成功动作会覆写为合法 UTC。 + +### 4. Task eligibility 和显式指派逻辑统一 + +> 2026-07-14 设计更新:后续真实运行证明自动领取会让任务责任边界变模糊,因此本节原有 claim 方案已被显式 Coordinator 指派取代。 + +- Ownerless 只表示“当前没人负责”,不会触发 Worker 自动领取、自动 Wake 或执行模式切换。 +- Watchdog 看到 ready ownerless task 时只通知 Coordinator 明确选择 `owner_member_id`;Inbox drain 和 resume 不改变任务 owner。 +- `eligible_member_ids` 现在是 Coordinator 可选择的候选白名单,不是 Worker 的抢单许可。 +- 新建 ownerless pending task 必须有非空 eligibility;owner 和 eligibility 必须属于 launch snapshot roster。 +- `metadata` 必须是 object,保留字段类型和值会在 store 层再次校验,不能只相信 tool 层。 + +### 5. 成员失败、shutdown 和 restart 使用明确 disposition + +成员异常失败: + +```text +open task + -> owner=NULL, status=pending, metadata 保留 + -> 不 wake failed owner,也不 wake eligible peer + -> coordinator 收到 awaiting_coordinator_assignment + task ids + -> coordinator 明确选择新的 owner 后,TaskAssigned 才能唤醒该成员 +``` + +这条最终语义刻意不再区分“是否存在 peer”:eligibility 只是 Coordinator 的候选白名单,不是 Worker 的抢单许可。 + +成员明确接受 shutdown: + +```text +有合法 peer -> released_to_pool +没有合法 peer -> owner=coordinator, status=pending +``` + +第二条是本轮审计补上的关键边界。主动 shutdown 是行政性停止,不应再被当成 provider failure 自动复活;交给 coordinator 后,任务仍合法、仍可见、但不会把已离开的成员叫回来。 + +App restart 时,遗留 Running session 先转为 Abandoned,再走相同的 failure disposition,然后 run 转为 Paused,等待用户明确恢复。扫描不再只取前 500 个 Running run。 + +### 6. Run finality 与 task mutation 原子化 + +- create/update/delete/reassign/requeue/shutdown disposition 都在 transaction 内检查 parent run 必须是 Running。 +- `reconcile_if_terminal` 在统一 writer lock + `IMMEDIATE` transaction 内读取 run、root session、worker sessions 和全部 task 状态,再 CAS 写 terminal status。 +- 新增 reconcile 与并发 task_create 测试;合法结果只能是:reconcile 先赢、create 被拒绝,或 create 先赢、run 因 open task 进入 Abandoned。 +- update 返回事务内的 `TaskMutationOutcome`,side effect 依据 before/after transition 触发,避免并发更新重复发送 TaskAssigned。 + +### 7. 删除 stale 自动抢任务 + +- 删除按 15 分钟年龄自动清 owner 的 production path、debug endpoint、常量和测试。 +- Running session 的旧任务只会提醒 coordinator,不会被 watchdog 偷走。 +- 只有明确 Failed/Cancelled/Abandoned/Timeout、accepted shutdown 或 restart recovery 才能改变 ownership。 +- 无法解析的 task/session timestamp 会告警并生成 repair,不会永久静默。 + +### 8. CLI Agent Org 能力边界变得诚实 + +当前 CLI member 没有 production inbox drain、Agent Org task tools 和正确的 resume bridge,因此本 PR 不再假装它能运行: + +- 新建/更新 org 时拒绝 CLI coordinator/member。 +- launch 前再次 preflight,必须在创建 run/root session 前失败。 +- 错误包含 `member_id` 和 `cli:*` transport。 +- Agent Org 设置和 Session Creator 的成员选择器不再展示 CLI agent。 +- 已有旧定义仍能反序列化和打开,用户可删除不支持的 CLI member。 +- 删除了宣称 CLI Agent Org 已可运行的正向 E2E;保留历史数据解析和负向 validation 覆盖。 + +## 用户现在看到的界面与行为 + +| 场景 | 现在的界面/行为 | +| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| 新建 Agent Org | coordinator 和 member 下拉只显示 Rust-native built-in/custom agents,不显示 CLI agents。 | +| 编辑旧 CLI Agent Org | 旧数据仍能打开;CLI 选项不会继续提供,保存前需移除/替换不支持成员。 | +| 启动含 CLI member 的旧 Org | 启动前明确报错,且不会留下半成品 run 或 root session。 | +| Run 被暂停 | unread inbox 保留,后台 wake 不调用 provider;恢复后由正常 progress wake 继续。 | +| Run 已结束 | task mutation 返回结构化不可变 guidance;排队中的 wake 执行时 no-op,不会复活成员。 | +| 成员失败 | 看板任务按 eligibility 释放或保留;coordinator 收到含 disposition、eligible_member_ids、required_role 的恢复说明。 | +| 成员主动 shutdown | 有 peer 时回池;无 peer 时交给 coordinator,不再自动复活已停止成员。 | +| 多个来源同时 wake | 用户不会看到多个空 turn;scheduler 只执行一个,其余 coalesced。 | +| 用户给 Coordinator 普通指令 | 不显示 intervention pin;Coordinator 仍能在该 turn 读取 worker 发来的未读信。 | +| 用户直接切到 worker 聊天 | 只对该 worker 显示 intervention pin;后台信保留,Return to work 后继续。 | +| 升级前留下 Coordinator intervention | 第一次读取时自动清除,不再要求用户 Pause/Resume 才恢复。 | +| Running 成员长时间没更新 | 只提醒 coordinator,不自动清 owner。 | +| App 重启 | 旧 intent 先收口;任务已全部 resolved 的 run 尝试原子完成;仍有工作/未读交接的 run 才暂停并保持看板可见。 | + +## 本轮代码审计发现并修复的问题 + +| 优先级 | 发现 | 风险 | 修复 | 状态 | +| ------ | --------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------- | +| P0 | debug task seed 使用错误 crate 根路径 | `agent_core` check 通过但顶层 `org2` 无法编译 | 改为公开的 `agent_core::...` import,并补跑 `cargo check -p org2` | 已修复 | +| P0 | 普通 Coordinator 指令被当成 worker takeover | 未读 worker 回执与 3 分钟 intervention 冲突,形成 270 个顺序 Wake intent 和蓝色闪烁 | Backend 按 canonical member_id 分类;Coordinator 永不建立 intervention;store 自愈旧记录;真实 UI 回归覆盖 | 已修复 | +| P1 | 通用 `agent_send_message` 从“非空文本”推断 intervention | 自动 continuation 或内部程序化消息也可能被当成用户接管 worker | 只有 direct-submit / queued-dispatch 显式发 takeover 信号;generic command 不再推断;direct-member API 保留显式语义 | 已修复 | +| P0 | accepted shutdown 的 sole-owner task 仍留给 Cancelled member | watchdog 会把主动停止成员当异常终止重试 | 无 peer 时把 owner 改为 coordinator,并写 `escalated_to_coordinator` history | 已修复 | +| P1 | failure hook 按 eligibility 唤醒 peer | Worker 会在未经过 Coordinator 明确分配时收到任务 Wake | 删除失败后的 peer 自动领取/Wake;任务变成 ownerless pending,并向 Coordinator 报告具体 task ids | 已修复 | +| P1 | recovery deadline 损坏时 budget probe 返回错误 | member 既不重试也不升级,可能永久静默 | 告警并 fail-open,成功动作覆写 deadline;新增测试 | 已修复 | +| P1 | restart recovery 只扫 500 个 running run | 大量历史 run 时后面的遗留任务得不到 disposition | 使用 `usize::MAX`,SQL 安全 clamp 到 `i64::MAX` | 已修复 | +| P2 | CLI 保存错误只显示成员名字 | 同名成员难定位,未满足 validation contract | 错误加入 `member_id=` 和 `cli:*` | 已修复 | +| P2 | 全局 rustfmt/prettier 带入无关格式变化 | 增加 review 噪声 | 逐文件反向清除确认的 formatting-only diff | 已修复 | +| P0 | Review/Test task 漏写 `blocked_by` 会静默当成可立即执行 | Reviewer/Tester 在没有上游产物时反复空跑,真实顺序与任务描述矛盾 | `task_create` 强制 `dispatch_policy`;依赖策略映射为底层 `blocked_by`;三阶段顺序测试 | 已修复 | +| P0 | The Boys 实机中 Coordinator 文字说等待 Implement/Review,结构化依赖却只列 Planner | Scheduler 忠实执行错误图,Tester 在 Implementer 完成前先交付 | `after_dependencies` 漏列非传递 open task 时返回 `requires_dependency_confirmation` 且不落库;补齐依赖或显式 `allow_parallel_with_unlisted_open_tasks=true` 后才创建 | 已修复并经后续真人运行复验 | +| P0 | scheduler 合并 duplicate Wake 后新 intent 仍为 queued | 所有人完成后 Run 永久无法 finality,用户必须 Pause/Resume | Scheduler 原子收口为 `coalesced`;queue full/closed 为 `rejected` | 已修复 | +| P1 | 旧版本遗留 queued intent 升级后仍卡住 | 修复只对新数据有效,历史 Run 仍坏 | 启动时 queued/optimistic→stale、running→failed;实机坏行已自愈 | 已修复 | +| P1 | 所有 task 已完成的 Running Run 在 restart 后仍被通用策略暂停 | 用户仍需手动 Resume 才完成 | 启动 pause sweep 前先走正常原子 finality,仅暂停仍需工作的 Run | 已修复 | +| P1 | `simulate-app-restart` 与生产启动序列漂移 | rendered E2E 测到的是假流程 | debug endpoint 与生产统一为七步同序,并扩展 typed E2E result | 已修复 | +| P1 | tagged dispatch enum 生成 LLM provider 不兼容的 `oneOf` | Coordinator 可能无法调用 task_create | wire schema 扁平化,边界内再解析为 typed enum;schema portability 测试 | 已修复 | + +审计结束时没有遗留本次范围内的 P0/P1/P2 actionable finding。 + +## Architecture Audit:10 层检查 + +| Layer | 检查内容 | 结论 | +| -------------------------- | ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| 1. Compilation correctness | `org2`、`agent_core`、`e2e-test`、TypeScript、ESLint、Node syntax | 本次改动编译/类型/语法通过;Agent Core Clippy 有 36 条仓库既有基线,见验证章节。 | +| 2. Dead code / duplication | 从 watchdog、resume、task tools、failure finalize、scheduler 业务入口正向追踪 | 删除 dead `find_available`、`blockers_resolved`、旧 stale-release path、旧 wake wrapper、自动 claim helper 和失真的 CLI 正向 E2E。Ownerless 统一进入 Coordinator repair。 | +| 3. Naming consistency | 搜索 stale/release/wake/owner 旧名和注释 | stale 常量只表示 notice,不再暗示 release;failure 与 shutdown history 使用不同 disposition 名称。旧符号 sweep 为零。 | +| 4. Semantic overloading | Run / Session / Task / Delivery / Budget 对照 | 五个状态源独立持久化。Idle 不代表 terminal,unread 不代表 wake accepted,Pending/eligible 不代表 Worker 可以自领。 | +| 5. Default branches | 审查 `SessionStatus`、run status、wake outcome 和时间戳 fallback | Session recovery 显式穷举;DB/read error fail-closed;损坏 deadline/timestamp 不静默。E3 是具名且有测试的限制。 | +| 6. Cross-domain leakage | Rust/CLI transport、core task store、UI picker | 历史 CLI 只在边界被识别,不再落入 Rust wake;shared picker 用 `hideCliAgents` 参数,不改变其他 CLI-only surface。 | +| 7. New-developer clarity | 函数/事件/注释是否准确表达副作用 | Analyzer、executor、doorbell、disposition、finality transaction 的职责明确;shutdown 不再复用 failure 的 kept-owner 含义。 | +| 8. Wire / serialization | scheduler response、task metadata、inbox payload、tool guidance | scheduler 的 duplicate/rejected 映射为持久终态;task dispatch schema 为 provider-compatible 扁平字段,边界内解析 typed policy;依赖遗漏用成功形态的 structured guidance 返回,不制造 trajectory error;typed metadata 在 tool + store 双层校验。 | +| 9. Init parity | production setup、unit sandbox、debug seed、restart path | recovery schema 在 production 和测试初始化;production startup 与 `simulate-app-restart` 同为七步;debug seed 生成合法 eligibility;launch preflight 在 run 创建前执行。 | +| 10. Resolver symmetry | member identity、assignment、run gate、session resolution | watchdog/resume/task/failure 都把 ownerless 解释为等待 Coordinator 指派;身份始终以 member_id 为主;wake 的 run_id 用 typed 参数,不从字符串反解析。 | + +## 状态所有权表 + +| 状态维度 | 唯一真相源 | 允许从其他状态猜吗? | +| ------------------- | ------------------------------ | -------------------- | +| Run 是否允许继续 | `agent_org_runs.status` | 不允许 | +| Member 是否正在执行 | `agent_sessions.status` | 不允许 | +| Task 归属/完成/依赖 | `agent_org_tasks` | 不允许 | +| 信是否未读 | `agent_inbox.read_at` | 不允许 | +| Wake 是否已排队 | scheduler idempotency registry | 不允许 | +| 自动恢复是否可再试 | `agent_org_recovery_attempts` | 不允许 | + +## 入口一致性矩阵 + +| 入口 | Running gate | 显式 assignment | 持久 inbox | 幂等 wake | Budget | +| ------------------------------ | ---------------------------------------------------------------------------: | --------------------------------: | -----------------: | --------------------------------: | ---------------------: | +| Watchdog | analyzer + executor + turn start | ownerless 只 repair Coordinator | 是 | 是 | 是 | +| Resume progress | dispatcher + turn start | 只投递已有 owner 的真实输入 | 是 | 是 | terminal retry 才计 | +| Task create/update side effect | mutation transaction + dispatcher | Coordinator 写 owner 后才投递 | 是 | 是 | terminal retry 才计 | +| Member failure finalize | task transaction + dispatcher | 清 owner,等待 Coordinator | coordinator notice | 是 | 不在 failure hook 消耗 | +| Accepted shutdown | task transaction | peer pool / coordinator ownership | MemberTerminated | coordinator wake 合并 | 不复活 stopped member | +| App restart | intent reconcile + failure disposition + resolved finality + remaining pause | 是 | 保留 | 旧队列收口;resume 后新 wake 生效 | 持久保留/按状态清理 | + +## 验证记录 + +| 命令/检查 | 结果 | +| -------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `cargo check -p org2` | 通过 | +| `cargo check -p e2e-test` | 通过 | +| Watchdog tests | 21 通过 | +| Inbox drain tests | 30 通过 | +| Lifecycle tests(单线程隔离) | 5 通过 | +| Task store/tool/run finality/scheduler targeted tests | 通过;41 个 task tool 测试含三阶段依赖链、The Boys 同形漏依赖确认、明确并行保留,scheduler 含 20 并发 wake,另含 startup intent/finality 与 reconcile/create race | +| `cargo test -p agent_core --lib -- --test-threads=1`(沙箱外) | **2951 通过,2 个既有基线失败** | +| `pnpm typecheck` | 通过 | +| `pnpm run lint` | 通过;修复 1 个本 diff 内的 Prettier 换行后全量复验通过 | +| `pnpm run check:circular` | 通过;5073 files,零 circular dependency | +| 两份修改的 `.mjs` 执行 `node --check` | 通过 | +| 隔离 desktop/WebDriver `agent-org-recovery-ui.spec.mjs` | **2/2 真实渲染场景通过**;证明 Coordinator 普通指令不建立 intervention、可 drain Planner 未读信,同时 Planner 直接聊天仍建立 intervention | +| 隔离 desktop/WebDriver `agent-org-pause-resume-ui.spec.mjs` | **8/8 真实渲染场景通过**;覆盖生产同序 restart simulation、暂停/恢复、历史 Run 自动恢复、成员/Coordinator 历史和侧栏 | +| `session_persistence` 单线程全量 | **31/32 通过**;唯一失败是既有测试硬编码另一开发者 `/private/var/folders/10/.../Users_junyu/...` 临时路径,与本 diff 无关 | +| `git diff --check` | 通过 | +| stale/dead symbol sweep | 旧 stale release、旧 wake helper、旧 availability helper均为 0 命中 | + +完整 Rust suite 的两个已知失败: + +1. `core::session::turn::entry::tests::skill_slash_command_accepts_newline_after_name`:测试期待 bundled e2e-testing skill content,但本地返回原始 slash command;与 Agent Org 改动无关。 +2. `core::tools::search_tool_tests::repo_path_and_repo_paths_conflict_returns_error`:search tool 既有参数冲突行为与测试期待不一致;与本 PR 无关。 + +`cargo clippy -p agent_core -p session_persistence --all-targets --no-deps -- -D warnings` 仍被仓库既有 Clippy 基线挡住,分布在 interaction、provider、memory、session launch 等旧代码;包含 `doc_lazy_continuation`、`too_many_arguments`、`clone_on_copy` 等。此次新增的 dispatch policy、scheduler intent 终态、startup recovery 和 parity 入口没有新增 Clippy 命中,按本 PR 约定未扩大为无关重构。 + +本机已构建带 WebDriver automation 的最新桌面二进制,并在全新隔离 home 上运行 `agent-org-recovery-ui.spec.mjs`:失败成员恢复 UI、默认 Agent Org 启动、生产 run phase、Coordinator 普通指令读取 Planner inbox、worker-only intervention 共 2 个 rendered 场景全部通过。数据库证据中 Planner→Coordinator 的目标信从 unread 变为 read,同时 intervention 表只出现 `sde-planner`,没有 Coordinator 记录。未运行与本轮直接修复无关的整套 `agent-org-settings-ui.spec.mjs`。 + +## 有意保留的设计边界 + +- **E3 仍延后**:一个 worker active 时,暂不做 member-level peer recovery。代码注释和测试明确锁定,未来应作为独立设计升级。 +- **Eligibility 仍在 metadata JSON**:目前通过 typed tool + store invariant + SQL JSON1 加固;join table 规范化留给独立 schema PR。 +- **CLI Agent Org 是明确禁用,不是“半支持”**:完整支持需要 CLI inbox drain、Agent Org tools、resume bridge 和 production E2E。 +- **DispatchCategoryPalette 有两套相近 option-building 实现**:本次为保持行为一致同时加了 `hideCliAgents`;后续可单独抽共享 selector,避免 UI picker 规则双写。本 PR 不为此扩大重构范围。 +- **`WakeNoop` 是执行期结果**:request 侧能区分 Enqueued/Coalesced/Paused/Terminal/Unavailable/Failed;排队后工作消失由 processor no-op 并记录日志,当前没有再向最初 caller 反向传播异步 NoWork。 + +## Commit-ready 判定 + +自动化层面可以提交,理由: + +- 本次范围内的编译、类型、lint、targeted tests 和 diff hygiene 均通过。 +- 完整 Rust suite 中所有 Agent Org/Recovery/Task/Wake 测试通过。 +- 两个全量失败和 36 条 Agent Core Clippy 报告均为可复现的既有基线,未由本 diff 引入;workspace Clippy 还会先被未修改的 `cursor-bridge-app` `question_mark` 告警阻断。 +- 审计发现的 P0/P1/P2 已全部修复并复验。 +- 无关 untracked 文件 `.atomcode/`、`docs/PR-GUIDE-issue-194.md`、`docs/cli-agent-launch-args-plan-2026-07-04.md` 未被修改或纳入本次范围。 + +但按仓库 `github-issue-fix-workflow`,The Boys 之后新增的依赖确认门属于可见调度行为变化,仍需用户在最新 dev 上做一次真人 Agent Org 验收。验收通过后才能把结论更新为最终 commit-ready 并执行 Husky commit。 + +建议提交标题: + +```text +fix(agent-org): harden recovery, wake delivery, and run finality +``` + +提交前还需真人确认依赖遗漏会被 Coordinator 修正或明确确认并行;两份审计文档应随代码一并纳入 commit,无关本地文件继续排除。 diff --git a/docs/architecture-audit-2026-07-12/GitProfiles.md b/docs/architecture-audit-2026-07-12/GitProfiles.md new file mode 100644 index 000000000..8e69a858c --- /dev/null +++ b/docs/architecture-audit-2026-07-12/GitProfiles.md @@ -0,0 +1,52 @@ +# Architecture Audit — Git Profiles + +Scope: reusable Git profile persistence and parsing in TypeScript, global Git config commands in Rust, and the Tauri command boundary. + +## Acceptance criteria + +- Multiple named identities persist locally and can be created, duplicated, edited, deleted, and selected. +- Activating a profile writes `user.name`, `user.email`, optional `user.signingKey`, and `commit.gpgSign` at global scope. +- Optional signing state from a previous profile cannot leak into the next profile. +- Existing Git connection and preference behavior remains on the Connections tab. +- Raw profile config supports exactly the identity fields the backend applies and rejects unsupported sections/keys. +- TypeScript, focused tests, Git crate compilation, and full desktop command registration pass. + +## Ten-layer audit + +| Line | Element | Verdict | Reason | Suggested change | +| ----------------------------------------------- | ----------------------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | +| `gitProfiles.ts:1`, `util.rs:502` | 1. Compilation correctness | pass | `npm run typecheck`, focused ESLint, `cargo check -p git`, and full `cargo check` pass. | None. | +| `GitProfilesTab.tsx:61`, `util.rs:555` | 2. Dead code and structural deduplication | pass | Live path is tab mount → `get_git_global_profile` → editor state → `set_git_global_profile`. Existing config read/write helpers are reused; no parallel Git subprocess implementation was introduced. | None. | +| `gitProfiles.ts:3`, `util.rs:505` | 3. Naming consistency | pass | `GitProfile` is the saved ORGII record; `GitGlobalProfile` is the wire/global-config shape; `GitUserIdentity` remains the effective repository identity lookup. Names distinguish persistence and resolution scopes. | None. | +| `GitProfilesTab.tsx:39`, `util.rs:502` | 4. Semantic overloading | pass | “Profile” consistently means an author/signing identity. “Connection” remains GitHub authentication/repository access and is not reused for identity switching. | None. | +| `util.rs:590` | 5. Default branch analysis | pass | Optional signing keys are explicitly written or unset. Commit signing is explicitly written as true or false. No catch-all branch silently retains the prior profile. | None. | +| `gitProfiles.ts:1`, `util.rs:490` | 6. Cross-domain leakage | pass | UI persistence stays in the Git integration module; subprocess/config operations stay in the Git crate. GitHub connection records only supply email suggestions and do not own Git author identity. | None. | +| `GitProfilesTab.tsx:163`, `util.rs:572` | 7. New-developer clarity | pass | “Activate” is the only path that mutates global Git config. Editing saved values only clears the active marker, making saved state versus applied state explicit. | None. | +| `gitProfiles.ts:16`, `util.rs:504` | 8. Wire protocol and serialization | pass | The camel-cased frontend payload is intentionally mapped by Tauri to the snake-cased Rust struct fields. The payload contains four bounded scalar fields and no generated schema or hidden data. | None. | +| `handler_list.inc:365`, `GitProfilesTab.tsx:61` | 9. Init parity | pass | There is one production read entry point and one production apply entry point, both registered in the canonical handler list. Empty local storage imports the same global shape later used for matching and applying. | None. | +| `gitProfiles.ts:68`, `util.rs:557` | 10. Resolver symmetry | pass | Name, email, signing key, and signing toggle are read from global scope and applied to global scope. All four fields participate in active-profile matching. | None. | + +## Term overloading table + +| Term | Meaning | Owner | Verdict | +| -------------- | ----------------------------------------------------------------- | -------------------------- | ------- | +| Git connection | Authentication/repository access for a Git provider | Sync connections API | keep | +| Git profile | Reusable author and commit-signing identity | Git Profiles settings | keep | +| Global profile | Identity values currently written to global Git config | Git crate command boundary | keep | +| Active profile | Saved profile whose every applied field matches global Git config | Git Profiles state | keep | + +## Resolver and entry-point matrices + +| Field | Read global | Persist saved profile | Compare active | Apply global | Clear when absent | +| ------------ | ----------- | --------------------- | -------------- | ------------ | ----------------- | +| Author name | yes | yes | yes | yes | required | +| Email | yes | yes | yes | yes | required | +| Signing key | yes | yes | yes | yes | yes | +| Sign commits | yes | yes | yes | yes | explicit false | + +| Entry point | Validate | Blocking isolation | Shared helpers | Result surfaced | +| ------------------------ | --------------------- | ------------------ | ------------------- | ---------------------- | +| `get_git_global_profile` | Git output normalized | `spawn_blocking` | global read helper | typed Tauri result | +| `set_git_global_profile` | name/email required | `spawn_blocking` | write/unset helpers | error or success toast | + +No systematic sweep candidates or deferred architecture fixes remain in this scope. diff --git a/docs/architecture-audit-2026-07-12/KanbanNamingCleanup.md b/docs/architecture-audit-2026-07-12/KanbanNamingCleanup.md new file mode 100644 index 000000000..ffc986fd2 --- /dev/null +++ b/docs/architecture-audit-2026-07-12/KanbanNamingCleanup.md @@ -0,0 +1,38 @@ +# Kanban naming cleanup — architecture audit + +## Acceptance criteria + +- [x] No retired management route or route metadata remains. +- [x] No compatibility migration remains for the former management tab identities. +- [x] Kanban actions, shortcuts, service methods, and ChatPanel entry points use one naming chain. +- [x] Work Items sidebar IDs describe their actual navigation role. +- [x] Route-only station mode, peek state, and focus state are deleted. +- [x] Source and documentation filenames contain no retired product vocabulary outside immutable changelog history. +- [x] TypeScript, focused lint/tests, locale parsing, and diff checks pass. + +## 10-layer audit + +| Layer | Coverage | Verdict | Evidence / reason | +| --------------------------------------- | -------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1. Compilation correctness | Covered | pass | Full `tsc --noEmit` and targeted ESLint pass with zero errors. No Rust implementation was changed by this cleanup. | +| 2. Dead code & structural deduplication | Covered | fix | Removed the dedicated route, route loader, app-mode branch, route-only station variant, peek/focus atoms, unused Workstation tab type, and obsolete translation keys. | +| 3. Naming consistency | Covered | fix | Actions, shortcuts, services, tests, UI labels, storage keys, modules, and sidebar IDs consistently use Kanban, Work Items, or the neutral internal Work Management host. | +| 4. Semantic overloading | Covered | fix | Kanban is the board/action name; Work Items is the expandable navigation group; Work Management is only the internal multi-section host boundary. | +| 5. Default branch analysis | Covered | pass | Unknown or absent management sections resolve to Kanban; unknown routes resolve through the normal Workstation code fallback and no longer activate a hidden management mode. | +| 6. Cross-domain concept leakage | Covered | fix | Shared AppShell and station-mode code no longer carries route-specific management state. The Work Management host owns only multi-section content coordination. | +| 7. New-developer clarity | Covered | fix | `openKanbanTab`, `openKanbanChatPanelTabAtom`, `KANBAN_MENU_ITEM_ID`, and `WORK_ITEMS_*` expose intent without requiring knowledge of a retired product name. | +| 8. Wire protocol & serialization | Covered | fix | No external wire protocol changed. The ChatPanel storage key is versioned to intentionally discard obsolete persisted tab identities instead of migrating them. | +| 9. Init parity | Covered | pass | Shortcut, Spotlight, action system, Start Page, ChatPanel plus menu, and app sidebar all converge on the same Kanban service/atom path. | +| 10. Resolver symmetry | Covered | pass | All four management sections use the same section-to-title, section-to-icon, tab activation, and sidebar-selection mappings. | + +## Term-overloading check + +| Term | Product meaning | Internal meaning | Verdict | +| --------------- | ------------------------------------------------------ | ---------------------------------------------------------------- | ---------------- | +| Kanban | Board destination and primary action | Default section of the management ChatPanel tab | aligned | +| Work Items | Expandable navigation group and project/work-item list | Semantic sidebar ID namespace | aligned | +| Work Management | Not displayed as product copy | Neutral host for Kanban, Projects, GitHub Issues, and GitHub PRs | keep with reason | + +## Systematic sweep + +The sweep covers casing variants, kebab/snake/camel identifiers, filenames, route strings, persisted type shims, action and shortcut IDs, translations, tests, comments, and audit documents. Historical changelog data is intentionally immutable and excluded from the live-reference criterion. diff --git a/docs/architecture-audit-2026-07-12/OrgtrackUsageAnalyticsOptimization.md b/docs/architecture-audit-2026-07-12/OrgtrackUsageAnalyticsOptimization.md new file mode 100644 index 000000000..163595a3b --- /dev/null +++ b/docs/architecture-audit-2026-07-12/OrgtrackUsageAnalyticsOptimization.md @@ -0,0 +1,165 @@ +# Orgtrack Usage Analytics — Optimization Plan + +**Date:** 2026-07-12 +**Scope:** `orgtrack` usage/cost analytics — pricing accuracy (Tier 1) and +parse/cache correctness (Tier 2). +**Status:** Tier 1 (T1.1–T1.4) and Tier 2 (T2.1–T2.3) implemented in the working tree +(uncommitted); `cargo check`, `typecheck`, and `lint` clean. Follow-ups below are parked. + +This plan targets two areas of the usage-analytics pipeline: turning token counts +into trustworthy dollar figures, and hardening the parse/cache layer so those figures +stay correct as source files change. It does **not** change the `.orgtrack` export, +impact-indexing, or commit-linking subsystems. + +--- + +## Current state (baseline) + +### Pricing + +- The only costing logic lives in `src-tauri/src/agent_sessions/unified_stats/accounting.rs`. + It uses four hardcoded per-Mtok rates (`DEFAULT_INPUT_COST_PER_MTOK = 3.0`, + `DEFAULT_OUTPUT_COST_PER_MTOK = 15.0`, `DEFAULT_CACHE_WRITE = 3.75`, + `DEFAULT_CACHE_READ = 0.30`). +- `resolve_model_pricing` reads a `model_pricing` SQLite table + (`WHERE ?1 LIKE model_pattern ORDER BY length(model_pattern) DESC`), **but that table + is never created or populated anywhere in the codebase** — so every lookup falls + through to the four defaults regardless of model. +- Costing reads only the native `session_token_usage` table. Imported/external session + tokens live in `imported_history_session_cache`, so those sessions almost always + report `cost_usd = 0`. +- There is no real-vs-estimated cost distinction. `UsageSourceLabel::{Local, Pooling}` + is derived from `KeySource` and is a **label only**, not a cost decision. +- Frontend: `$` cost is rendered **only** in the Sessions view (`session_usage_list`). + Other Usage overview, the Cursor panel, and the CLI panel show tokens with no cost, + even though `AggregateStats.total_cost_usd` exists on the backend. + +### Parse / cache correctness + +- Imported-history cache invalidation + (`crates/orgtrack-core/src/sources/imported_history/metadata.rs`) compares + `source_path + source_mtime_ms + source_size_bytes + source_fingerprint + +parser_version`. This is solid for Claude/Codex (title-aware fingerprints) but + **Windsurf and OpenCode use a bare mtime string** as the fingerprint — a same-mtime + content change can be missed. +- Cache signatures use millisecond mtime (`paths.rs::file_metadata_signature`), which + is coarse enough to miss rapid in-place edits. +- SQLite-backed sources do not fold WAL/`-shm` sidecars into the change signature, so a + new session written but not yet checkpointed may not invalidate the cache. +- The unified CLI scanner (`crates/orgtrack-core/src/sources/cli_session_db.rs`) uses + hand-rolled substring JSON extraction (`extract_json_string_field` / `extract_json_i64`) + rather than a real parser. Aider token counts are always `0` and message counts are a + rough `lines / 4` estimate. + +--- + +## Tier 1 — Pricing accuracy + +**Goal:** every session that has tokens gets a trustworthy dollar figure, and the UI +can show recorded vs estimated cost. + +### T1.1 — Bundled model-price catalog + +- Add a generated catalog data file (JSON) of per-model input/output/cache-read/ + cache-write rates, plus a small loader. +- Populate the `model_pricing` table from the bundled catalog on schema init (or query + the catalog directly and drop the dead table lookup). +- Normalize model ids before lookup (case-fold, treat `.`/`-` equivalently, strip + date-pin and effort suffixes) so `claude-sonnet-4-5-20250101` and + `claude.sonnet.4.5` resolve to one rate. +- Lookup order: exact id → normalized id → longest-prefix family fallback → + mid-range default. Local/self-hosted providers price at `$0`. +- Owner: `accounting.rs`, a new `pricing_catalog` module, `crates/orgtrack-core` schema + init, plus the catalog data file. + +### T1.2 — Cost imported/external tokens + +- Extend costing so `imported_history_session_cache` tokens are priced through the same + catalog, not just `session_token_usage`. Imported sessions must stop reporting `$0` + purely because their tokens live in a different table. + +### T1.3 — Recorded vs estimated cost + +- Carry two cost figures per aggregate session: **recorded** (real metered spend) and + **estimated** (tokens × catalog list price). For subscription/own-key routes with no + metered cost, recorded is `$0` and estimated is the list-price figure. +- Decide recorded-vs-estimated per route where the information exists (metered API key + vs subscription/OAuth login), rather than a cosmetic local/pooling label. +- Expose both on `AggregateStats` / `UsageRecord` and the `orgtrack_*` command payloads. + +### T1.4 — Surface cost in every usage view (frontend) + +- Add a cost column and a `$` / tokens toggle to the Other Usage overview, Cursor panel, + and CLI panel (`src/modules/MainApp/DevRecord/views/OtherUsageView/*`). +- For token-only sources, default the toggle to the estimate with an `ESTIMATED` marker, + matching the existing Sessions-view cost formatting. +- Extend the frontend types (`src/api/tauri/orgtrackHistory/types.ts`) with the + recorded/estimated cost fields. + +--- + +## Tier 2 — Parse / cache correctness + +**Goal:** cached rollups never go stale silently, and CLI parsing is robust. + +### T2.1 — Stronger cache fingerprints + +- Move cache signatures to nanosecond mtime granularity. +- Fold SQLite WAL/`-shm` sidecars into the change signature for db-backed sources so a + not-yet-checkpointed write invalidates the cache. +- Replace the bare-mtime fingerprints for Windsurf and OpenCode with content-aware + fingerprints (e.g. row counts / latest-updated markers), consistent with the + Claude/Codex approach. +- Owner: `imported_history/metadata.rs`, `imported_history/cache.rs`, `paths.rs`, + `sources/windsurf/history.rs`, `sources/opencode/history.rs`. + +### T2.2 — Robust CLI parsing + +- Replace the hand-rolled substring JSON extraction in `cli_session_db.rs` with real + `serde_json` parsing per tool. +- Fix Aider token accounting (currently always `0`) and replace the `lines / 4` message + estimate with an actual count where the format allows. + +### T2.3 — Token-accounting quirk verification + +- Verify per-source token accounting handles known edge cases without double-counting: + resumed/forked session overlap, cumulative-counter deltas with context-compaction + resets, and multi-record dedup. Document confirmed-correct vs fixed per source. + +--- + +## Discovered follow-ups (parked) + +Surfaced during T2.3 verification and the pricing work; not fixed under this pass: + +- **FU1 (high) — Claude resume/fork double-count.** Claude sessions are cached with + `parent_session_id: None` and no cross-file message-`uuid` dedup, so `--resume`/fork + transcripts (which replay prior turns with the same usage into a new JSONL) are + counted twice. Pricing makes this visible in dollars — highest-value next fix. + Owner surface: `crates/orgtrack-core/src/sources/claude_code/history.rs`, + `.../imported_history/cache.rs`. +- **FU2 — Codex compaction-reset undercount.** Last-wins on the cumulative + `total_token_usage` drops pre-compaction tokens if Codex resets after a context + compaction (undercount, not double-count). +- **FU3 — Cursor `state.vscdb` sidecar folding.** The nanosecond/WAL fingerprint helper + (`sqlite_sidecar_signature`) exists and is adopted by OpenCode/Windsurf; Cursor's + `cursor_ide/db.rs` reader has not yet adopted it. +- **FU4 — Per-session recorded cost in the detail command.** The single-session + `session_usage_summary` path has no route context, so it reports `recorded = $0` / + `estimated = list price`. Aggregate/heatmap/usage-list paths are route-aware. + +## Out of scope + +- `.orgtrack` on-disk export, reachability, git-blame, and sync-record generation. +- Impact indexing and commit linking. +- New source integrations (tracked separately). + +--- + +## Verification + +- Backend: `pnpm cargo:check` and the relevant `cargo:test:*` targets must pass. +- Frontend: `pnpm typecheck` and `pnpm lint` must pass; cost columns exercised against + real session data in the running app. +- No source files under a user's tool directories are written — read-only guarantee is + preserved. diff --git a/docs/architecture-audit-2026-07-12/SharedAccountDetails.md b/docs/architecture-audit-2026-07-12/SharedAccountDetails.md new file mode 100644 index 000000000..8a44dbc0f --- /dev/null +++ b/docs/architecture-audit-2026-07-12/SharedAccountDetails.md @@ -0,0 +1,47 @@ +# Shared Account Details Architecture Audit + +Scope: extracting Key Vault account details from the Settings ownership tree and consuming them from both Settings and the Chat launchpad. + +## Completion checklist + +- [x] TypeScript typecheck succeeds. +- [x] ESLint succeeds for every touched TypeScript/TSX file. +- [x] Existing Chat panel start-page tests succeed. +- [x] One production implementation owns account detail, status, compatibility, and badge behavior. +- [x] Shared modules do not import from `MainApp`. +- [x] Chat defers detail code and detail-specific compatibility requests until selection. +- [x] Old Settings import paths are compatibility re-exports rather than duplicate implementations. + +## Ten-layer findings + +| Line | Element | Verdict | Reason | Suggested change | +| -------- | ------------------------ | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------- | +| Layer 1 | Compilation correctness | pass | `npm run typecheck -- --pretty false`, scoped ESLint, and the focused Vitest suite all pass. | None. | +| Layer 2 | Structural deduplication | pass | `AccountInlineDetails`, compatibility, badges, status colors, and layout primitives each have one implementation. Existing Settings paths only re-export them. | Remove compatibility re-exports only when all downstream imports have migrated. | +| Layer 3 | Naming consistency | pass | The canonical name describes the rendered body (`AccountInlineDetails`); the former `AccountInlineStatusSection` survives only as a compatibility alias. | Prefer the canonical name in new code. | +| Layer 4 | Semantic overloading | pass | `account` consistently means `KeyVaultAccount`; `status` is account readiness/health; `details` is presentation over the same account record. No term carries a second domain meaning. | None. | +| Layer 5 | Default branches | pass | Selection explicitly toggles open/closed. Async completion clears only the matching account ID, preventing an older refresh from clearing a newer selection's loading state. Failed refresh retains cached account data. | None. | +| Layer 6 | Cross-domain leakage | pass | `src/modules/shared/keyVault` contains no imports from `src/modules/MainApp`; Settings depends on shared code, not the reverse. | Keep this dependency direction. | +| Layer 7 | New-developer clarity | pass | Layout primitives, account-domain components, and launchpad orchestration have separate owners and purpose-based names. | None. | +| Layer 8 | Wire protocol | not applicable | No wire type or serialized payload changed. The launchpad calls the existing `refreshAccount(accountId, true)` boundary. | None. | +| Layer 9 | Entry-point parity | pass | Settings and Chat render the same canonical detail component from the same `KeyVaultAccount` shape. Chat adds its surface-specific footer and on-demand refresh only. | Keep surface chrome outside the canonical body. | +| Layer 10 | Resolver symmetry | pass | Both entry points use the same internal quota, overview, credential, badge, and compatibility resolvers because they share the component implementation. | None. | + +## Entry-point matrix + +| Entry point | Account source | Detail renderer | Compatibility resolver | Status renderer | Surface behavior | +| ------------------ | ----------------------------- | ---------------------------------- | ------------------------------------ | ---------------------------------------------- | ------------------------------------------------- | +| Key Vault Settings | `KeyVaultAccount` table row | Shared `AccountInlineDetails` | Shared `AccountCompatibilitySection` | Shared `AccountStatusIndicator` via action bar | Existing inline table expansion | +| Chat launchpad | `useKeyVault().localAccounts` | Lazy shared `AccountInlineDetails` | Mounted only after selection | Shared `AccountStatusIndicator` in footer | Refresh on selection, inline expansion after tile | + +## Default and async branch matrix + +| Condition | Result | +| ----------------------------------- | ----------------------------------------------------------------------- | +| No selected account | No detail code mount and no compatibility request | +| Select an account | Mark selected, refresh that account, dynamically import detail renderer | +| Select the same account again | Collapse the detail surface | +| Select B before A refresh completes | B remains loading; A's completion cannot clear B's loading state | +| Refresh fails | Loading clears and the existing cached account remains available | + +No wire-payload, fallback-chain, FSM, or serialization changes were introduced, and no systematic issue class outside the extracted ownership boundary was found. diff --git a/docs/architecture-audit-2026-07-12/WorkManagementGitHubLazyListsCache.md b/docs/architecture-audit-2026-07-12/WorkManagementGitHubLazyListsCache.md new file mode 100644 index 000000000..610096b1c --- /dev/null +++ b/docs/architecture-audit-2026-07-12/WorkManagementGitHubLazyListsCache.md @@ -0,0 +1,43 @@ +# Kanban GitHub lazy lists and cache — architecture audit + +## Acceptance criteria + +- Kanban fetches open pull requests on entry and fetches closed pull requests only when the Closed or Merged view is selected. +- Closed pull-request results include both closed-unmerged and merged pull requests. +- Issues, open PRs, and closed PRs reuse one shared global list cache rather than maintaining Workstation- and Ops-specific copies. +- Fresh cache entries suppress repeat list requests for 10 minutes; manual refresh bypasses the TTL for the visible state. +- Open and closed issue freshness are tracked independently so refreshing one state cannot extend the other state’s lifetime. +- Rapid unmount/remount cycles share an in-flight request, and completed/rejected promises are removed without timers or subscriptions. +- Cache hydration and writes are both LRU-bounded: four issue repositories, eight PR repo/state lists, and twenty PR details. +- Kanban retains only one active page/query snapshot per GitHub scope and evicts it on access after 10 minutes. +- Switching between Issues, PRs, and other Kanban sections restores the active page without retaining component trees. +- Targeted ESLint, TypeScript, cache tests, query-state tests, and pagination tests pass. + +## Ten-layer audit + +| Layer | Coverage | Verdict | Evidence / reason | +| ------------------------------------- | ----------------------------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1. Compilation correctness | TypeScript and changed-file lint | pass | `tsc --noEmit`, targeted ESLint, and nine focused tests pass. Rust is unchanged. | +| 2. Dead code / structural duplication | GitHub list-cache ownership | pass | The cache moved from a Workstation hook directory to `services/git`; My Station and Kanban now import the same live implementation. | +| 3. Naming consistency | Cache, state, and view terminology | pass | `openPrs`, `closedPrs`, `openLoaded`, `closedLoaded`, and `OpsGitHubViewSnapshot` distinguish remote state, load lifecycle, and UI position. | +| 4. Semantic overloading | “page”, “state”, “cache”, and “closed” | pass | Page means the 25-row client page; PR state means GitHub open/closed request state; Closed UI intentionally includes GitHub `merged` results returned by the closed endpoint. | +| 5. Default branches | PR query-state resolution | pass | Open/null resolves only open, Closed/Merged resolves only closed, and explicit All resolves both; focused tests cover every branch. | +| 6. Cross-domain leakage | Workstation ↔ Kanban | pass | Data caching is owned by the shared Git service. Ops-only query/page snapshots remain local to Kanban and do not leak UI concerns into the service. | +| 7. New-developer clarity | Cache lifetime and cleanup | pass | Constants document the exact 10-minute TTL and LRU bounds; in-flight request cleanup and read-time snapshot expiry explain why no background timer is needed. | +| 8. Wire protocol / serialization | GitHub PR list command and local persistence | pass | The existing `github_list_prs` payload remains `{ repoFullName, state, perPage }`; no Rust command or external schema changed. Bounded list caches continue using versioned localStorage keys. | +| 9. Init parity | Open, Closed, Merged, All, refresh, and remount entry paths | pass | Every PR list state follows repo resolution → cache seed → TTL decision → coalesced request → cache update. Closed differs only by deliberate lazy activation. | +| 10. Resolver symmetry | Open/closed issue and PR cache/fetch chains | pass | Issue sections have independent timestamps; both PR states use identical cache keying, staleness checks, request coalescing, network fallback, error fallback, loaded flags, and LRU persistence. | + +## Cache bounds and lifecycle + +| Cache | Key | Bound | Expiry / cleanup | +| --------------- | ----------------------------------- | -----------------------------------------------: | -------------------------------------------------- | +| Issues | repository path | 4 repositories; 200 rows per open/closed section | 10-minute access check; LRU on hydration and write | +| PR lists | repository path + open/closed state | 8 lists; 100 rows per list | 10-minute access check; LRU on hydration and write | +| PR details | repository + PR number | 20 details | 10-minute access check; memory-only LRU | +| Ops view | issue/PR scope | exactly 2 snapshots; one current page/query each | 10-minute read-time eviction; memory-only | +| In-flight lists | request kind + state + repository | active requests only | deleted in `finally` after resolve or reject | + +## Scoped-out layers + +No Rust, Tauri command signature, GitHub authentication, issue/PR mutation, database schema, session runtime, or queue lifecycle changed. The cache continues to treat GitHub as authoritative and is bypassed by explicit refresh. diff --git a/docs/architecture-audit-2026-07-12/WorkManagementGitHubListUnification.md b/docs/architecture-audit-2026-07-12/WorkManagementGitHubListUnification.md new file mode 100644 index 000000000..9bac6560d --- /dev/null +++ b/docs/architecture-audit-2026-07-12/WorkManagementGitHubListUnification.md @@ -0,0 +1,58 @@ +# Kanban GitHub list unification — architecture audit + +## Acceptance criteria + +- [x] Issues and PRs use one list frame, row shell, summary component, and pager. +- [x] Issue-only and PR-only metadata remain explicit at their call sites. +- [x] Sidebar presets use the existing shared tree-row primitive. +- [x] The Issues filter uses the same normal-section height allocation as Views. +- [x] PRs do not create an empty second-level sidebar section. +- [x] Open/create/refresh actions use one shared component in the search toolbar. +- [x] The redundant search-row result count is removed. +- [x] Pagination policy is a pure tested module. +- [x] No parallel sidebar filter state is introduced; search text remains canonical. +- [x] Issue and PR detail views hide the sidebar without mutating the saved collapse preference. +- [x] TypeScript, targeted lint, tests, formatting, and whitespace checks pass. + +## Ten-layer audit + +| Layer | Coverage | Verdict | +| ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | +| 1. Compilation correctness | Full frontend TypeScript check plus targeted ESLint. | Pass. | +| 2. Dead code and structural deduplication | Traced the rendered path from `GitHubWorkItemsSurface` to both issue and PR rows. Removed the local hand-styled filter list, segmented-pill path, duplicate toolbar action markup, empty PR sidebar host, and auto-height global-section path; wired every new shared component into its applicable scopes. | Pass; no aspirational abstraction remains. | +| 3. Naming consistency | Shared exports consistently use the `GitHubWorkItem*` prefix. Pagination helpers keep the same domain prefix. | Pass. | +| 4. Semantic overloading | `work item` means a GitHub issue/PR only inside this local Kanban module; ORG2 project work items remain separate. `filter` in the sidebar is represented as a preset that writes the canonical GitHub search query. | Keep local naming; do not promote these types into the project-work-item domain. | +| 5. Default branch analysis | Preset selection uses explicit assigned/authored/closed/all branches. Unknown keys do not mutate the query, and callers can only supply declared options. | Pass for the closed option set. | +| 6. Cross-domain concept leakage | Shared components accept render slots and labels; they do not import issue or PR API models. `TreeRowBase` remains unaware of GitHub query semantics. | Pass. | +| 7. New-developer confusion | `GitHubWorkItemRow`, `GitHubWorkItemSummary`, `GitHubWorkItemPagination`, `GitHubWorkItemSidebarFilters`, and `GitHubWorkItemToolbarActions` identify layout ownership directly. Domain metadata stays in `ManagedIssueRow` and `ManagedPrRow`; `onDetailViewChange` explicitly names the only surface-to-shell state projection. | Pass. | +| 8. Wire protocol and serialization | No wire payload, API request shape, or serialization changed. Existing GitHub responses are only rendered and client-filtered. | Intentionally not applicable. | +| 9. Init parity | No new entry point or initialization flow. Issue and PR surfaces continue through the same component entry point and scope prop. | Intentionally not applicable. | +| 10. Resolver symmetry | No multi-source resolver changed. Repository and query resolution remain shared before scope-specific rendering. | Intentionally not applicable. | + +## Term overloading table + +| Term | Meaning here | Adjacent meaning | Decision | +| --------- | ------------------------------------------------------ | ----------------------------------------------------------- | ------------------------------------------------------------- | +| Work item | A list-renderable GitHub issue or PR. | An ORG2 project work item. | Keep the `GitHubWorkItem` prefix on every shared symbol. | +| State | GitHub open/closed query state. | UI loading/detail state and ORG2 work-item lifecycle state. | Keep `GITHUB_QUERY_STATE` scoped to this module. | +| Filter | A named sidebar preset that rewrites the search query. | Repository selector and free-text qualifiers. | Keep one canonical serialized query; do not add filter atoms. | + +## Default branch matrix + +| Preset | State | Assignee | Author | +| -------------- | ------ | -------- | ------- | +| Assigned to me | open | `@me` | cleared | +| Created by me | open | cleared | `@me` | +| Closed | closed | cleared | cleared | +| All states | all | cleared | cleared | + +## Structural sweep + +- Searched the Kanban GitHub surface for `TabPill`, the old quick-filter arrays, and the local `FilterOptionList`; no obsolete path remains. +- Both issue and PR rows render through `GitHubWorkItemRow`. +- Both scopes render through `GitHubWorkItemListFrame` and `GitHubWorkItemPagination`. +- Sidebar filter rendering uses `TreeRowBase`, matching the existing Views list rather than duplicating its Tailwind styling. +- Issues alone publish second-level sidebar content, containing only shared tree rows with no custom inset wrapper and using the same `flexGrow: 1` section allocation as Views. +- Both Issues and PRs render search-toolbar actions through `GitHubWorkItemToolbarActions`. +- No search-row result-count label remains. +- Detail openness has one source in `GitHubWorkItemsSurface`; the parent shell only derives sidebar collapse and toggle disabled state from it. diff --git a/docs/architecture-audit-2026-07-12/WorkManagementSidebarRemoval.md b/docs/architecture-audit-2026-07-12/WorkManagementSidebarRemoval.md new file mode 100644 index 000000000..a7e4f032b --- /dev/null +++ b/docs/architecture-audit-2026-07-12/WorkManagementSidebarRemoval.md @@ -0,0 +1,38 @@ +# Kanban sidebar removal — architecture audit + +Scope: remove the nested Kanban primary-sidebar implementation after moving its destinations into the expandable app-sidebar item. + +## Acceptance criteria + +- [x] Kanban has no nested `WorkStationShell` or primary-sidebar configuration. +- [x] No production reference remains to the removed sidebar component, width/collapse atoms, responsive helper, or focused hook. +- [x] Kanban and Work Items destination selection has one source of truth: the existing internal Kanban section/project-view atoms. +- [x] Chat-pane titles and icons derive from that same active section and expose only destination names. +- [x] TypeScript compilation, targeted lint, and targeted navigation/Kanban tests pass. + +## 10-layer audit + +| Layer | Coverage | Verdict | Evidence / reason | +| --------------------------------------- | -------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1. Compilation correctness | Covered | pass | Full `tsc --noEmit` passes after deleting the component, hook, atoms, and re-exports. No Rust was touched. | +| 2. Dead code & structural deduplication | Covered | fix | Removed `WorkManagementSidebar`, responsive collapse helper/test, Ops-specific persisted width/collapse atoms, `useWorkManagementSidebarState`, and both re-export paths. A repository sweep confirms no remaining production references. | +| 3. Naming consistency | Covered | fix | User-facing chat-pane names now match the navigation destinations: Kanban, Projects, GitHub Issues, and GitHub PRs. Internal `work-management` IDs remain stable implementation identifiers. | +| 4. Semantic overloading | Covered | fix | Removed the visible product aliases from the chat pane. `work-management` is only the internal singleton host/type name, while each visible destination has one product label. | +| 5. Default branch analysis | Covered | pass | Destination and tab-title mappings explicitly cover Kanban, Projects, GitHub Issues, and GitHub PRs. An absent or unknown section safely resolves to Kanban. | +| 6. Cross-domain concept leakage | Covered | pass | Work Items navigation remains in the Workstation sidebar connector and Work Management module; shared Workstation panel state no longer carries route-only management state. | +| 7. New-developer confusion | Covered | fix | Removed the misleading parallel `usePrimarySidebarState` / `useWorkManagementSidebarState` APIs. The expanded parent/child menu now expresses the visible hierarchy directly. | +| 8. Wire protocol & serialization | Covered | fix | No network schema changed. The versioned ChatPanel storage key intentionally discards obsolete management-tab identities instead of migrating them. | +| 9. Init parity | Not applicable | skipped | No initialization entry point or runtime registration path changed. | +| 10. Resolver symmetry | Not applicable | skipped | No multi-source resolver or fallback chain changed. | + +## Term-overloading check + +| Term | Before | After | Verdict | +| -------------- | ---------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | +| Kanban sidebar | Could mean the global app-sidebar item or the nested resizable rail | User-facing navigation is Kanban plus expandable Work Items; no nested sidebar remains | clarified | +| View | Mixed destination navigation with Kanban/List/Diary presentation modes | Kanban is top-level; Work Items expands to the existing project/work-item list and related destinations; presentation modes are in the 40px header | clarified | +| Management tab | Previously displayed under multiple product aliases | The chat tab always displays its active destination: Kanban, Projects, GitHub Issues, or GitHub PRs | clarified | + +## Systematic sweep + +Searched for `WorkManagementSidebar`, `workManagementResponsiveLayout`, `useWorkManagementSidebarState`, `workStationWorkManagementSidebar`, `work_management_sidebar_width`, and `work_management_sidebar_collapsed`. All production definitions, imports, re-exports, tests, and persistence reads/writes were removed. Historical documentation was left intact as an audit trail. diff --git a/docs/architecture-audit-2026-07-12/launchpad-manage-consolidation.md b/docs/architecture-audit-2026-07-12/launchpad-manage-consolidation.md new file mode 100644 index 000000000..b0057cf3a --- /dev/null +++ b/docs/architecture-audit-2026-07-12/launchpad-manage-consolidation.md @@ -0,0 +1,35 @@ +# Launchpad Manage consolidation architecture audit + +## Acceptance criteria + +- Explore is renamed to Manage in all supported locales. +- The former workspace Dashboard renders only inside Launchpad Manage. +- No standalone Dashboard ChatPanel tab type, creator atom, icon branch, or plus-menu entry remains. +- Persisted Dashboard tabs migrate to Launchpad. +- Folders Dashboard navigation focuses Launchpad Manage without duplicating a Launchpad tab. +- Manage is lazy-loaded and unmounted when Work or Trends is selected. +- Work actions reuse the original rounded pill geometry; tones remain on containers and icons remain neutral. +- TypeScript, targeted ESLint, and focused state/UI tests pass. + +## Ten-layer audit + +| Layer | Coverage | Verdict | Evidence / reason | +| ------------------------------------- | --------------------------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1. Compilation correctness | TypeScript and changed-file lint | pass | `tsc --noEmit` and targeted ESLint complete successfully. Rust is untouched. | +| 2. Dead code / structural duplication | Dashboard tab and render path | pass | Removed the `dashboard` ChatPanel tab variant, add-tab atom, icon/menu branches, display resolver case, and standalone `ChatPanelContent` render branch. | +| 3. Naming consistency | Start-page tabs | pass | The canonical inner identities are now `work`, `manage`, and `heatmap`; user-facing labels are Work / Manage / Trends. | +| 4. Semantic overloading | Dashboard vs. Launchpad | pass | Dashboard is retained only as a legacy navigation/migration term; workspace management UI has one live owner under Launchpad Manage. | +| 5. Default branches | Initial tab and legacy navigation | pass | Launchpad defaults to Work. Legacy Dashboard navigation redirects to Manage, and persisted Dashboard tabs normalize to `start-page`. | +| 6. Cross-domain leakage | Launchpad ↔ workspace management | keep with reason | `WorkspaceDashboardPanelView` remains a thin adapter over the shared launchpad module; ChatPanel owns only lazy hosting and inner-tab lifecycle. | +| 7. New-developer clarity | Open/focus actions | pass | `openOrFocusChatPanelManageTabAtom` states its behavior and centralizes sidebar-to-Manage navigation. | +| 8. Wire protocol / serialization | Local tab persistence | pass | No external protocol changes. LocalStorage normalization explicitly maps old `dashboard` and `launchpad` tab identities to Launchpad. | +| 9. Init parity | Plus menu, new session, sidebar | pass | New session and Launchpad entry points reset to Work; Folders Dashboard focuses Manage; all paths use the same start-page state atom. | +| 10. Resolver symmetry | Tab title, sidebar selection, inner tab | pass | Launchpad title resolution comes from `start-page`; folder Dashboard selection derives from `start-page + manage`; no Dashboard tab resolver remains. | + +## Lifecycle / memory boundary + +`WorkspaceDashboardPanelView` is declared with `React.lazy` and rendered only by the `manageTabActive` conditional. React unmounts it when the active inner tab changes, releasing its repo, key-vault, agent-catalog, container, and container-engine subscriptions plus local selection state. The loaded JavaScript chunk remains browser-cached, as expected, but the heavy live component graph is not retained. + +## Scoped-out layers + +No Rust, database, external wire protocol, session launch protocol, or container backend behavior changed. Workspace detail and legacy Explore surfaces remain available through their existing sidebar paths. diff --git a/docs/architecture-audit-2026-07-12/work-management-chat-tab.md b/docs/architecture-audit-2026-07-12/work-management-chat-tab.md new file mode 100644 index 000000000..8d95d6b0a --- /dev/null +++ b/docs/architecture-audit-2026-07-12/work-management-chat-tab.md @@ -0,0 +1,48 @@ +# Kanban → ChatPanel tab migration architecture audit + +## Acceptance criteria + +- The Workstation no longer mounts a dedicated Kanban station view or tab bar. +- Kanban is a singleton ChatPanel tab; Projects is an internal section of that tab. +- The active management tab is the source of truth for its title, rendered content, and app-sidebar selection. +- The active management tab also drives the outer Workstation sidebar highlight; no dedicated route or route-only station mode remains. +- Every tab pill resolves from its canonical tab type or linked entity; surface-header and globally active-session titles cannot override another tab's identity. +- Selecting a session in the Workstation sidebar focuses its existing session tab or creates and activates one; Launchpad cannot remain the active tab while session content opens. +- The Kanban tab defaults to full-screen ChatPanel presentation on entry and restores the user's prior maximize state on exit when the user has not explicitly restored the Workstation. +- The management tab keeps the top tab bar and maximize/Workstation toggle so full screen remains user-reversible, while suppressing the focused Workstation rail. +- Launchpad names the Work / Explore / Trend start page; Dashboard names the workspace summary surface and uses an Info icon. +- Every ChatPanel tab is closable; closing the final tab creates and activates the three-section Launchpad. +- Closing Kanban disposes its transient creator, preview, replay-event, playback, and header state while retaining persisted user preferences. +- The chat-tab storage key is versioned; stale management tabs are discarded instead of migrated. +- The app sidebar owns Kanban and Work Items navigation; List and Diary remain presentation modes in the 40px content header. +- Shortcut, Spotlight, action, Start Page, plus-menu, and sidebar entry points converge on one `openKanbanTab()` service path. +- Removed Workstation tab types/renderers have zero remaining references. +- Targeted ESLint, TypeScript, and tab-state tests pass. + +## Ten-layer audit + +| Layer | Coverage | Verdict | Evidence / reason | +| ------------------------------------- | ------------------------------------------------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1. Compilation correctness | TypeScript + changed-file lint | pass | `pnpm exec tsc --noEmit --pretty false` and targeted ESLint complete with zero errors. Rust is untouched. | +| 2. Dead code / structural duplication | Old station path, duplicate identity, retained UI state | pass | Deleted the retired management route, route-only station mode, peek/focus atoms, and Projects tab type; transient cleanup remains centralized in the canonical tab-close path. | +| 3. Naming consistency | Chat tab and service names | pass | `start-page` is the Launchpad with Work / Explore / Trend; `dashboard` is the workspace summary; `work-management` owns both management sections under one visible tab identity. | +| 4. Semantic overloading | `launchpad`, `dashboard`, `project`, `station`, `tab` | pass | Launchpad and Dashboard no longer label the same surface, surface-header context no longer doubles as tab identity, and Projects is explicitly an inner management section rather than another tab. | +| 5. Default branches | Tab activation, presentation, and empty-tab fallback | pass | Every tab variant explicitly synchronizes its surface state; management/terminal tabs use Session only as a neutral underlying surface. Final close explicitly activates Launchpad. | +| 6. Cross-domain leakage | ChatPanel ↔ Kanban | keep with reason | ChatPanel owns surface identity/presentation; Kanban continues to own its sidebar and management content. The shell lazy-load is an intentional host boundary, not duplicated domain logic. | +| 7. New-developer clarity | Entry points and ownership | pass | `openKanbanChatPanelTabAtom`, `isChatPanelTabDefaultFullscreen`, and `openKanbanTab` distinguish entry defaults from enforced presentation. | +| 8. Wire protocol / serialization | External payloads and local persistence | pass | No backend protocol changed. The versioned ChatPanel storage key intentionally drops obsolete management-tab identities; terminal tabs remain excluded from persistence. | +| 9. Init parity | Shortcut, Spotlight, action, plus menu, sidebar | pass | All Kanban entry points converge on one tab atom; session rows focus linked tabs, while sidebar New Chat resets the draft and then creates and activates the localized Launchpad tab. | +| 10. Resolver symmetry | Tab presentation and management selection | pass | Kanban and Projects follow the same activation chain; title, tab identity, content section, and outer sidebar highlight all resolve from the same active tab. | + +## Entry-point parity matrix + +| Entry point | Opens singleton tab | Makes chat visible | Defaults full screen | Workstation restorable | Selects section | +| ----------------------------- | ------------------: | -----------------: | -------------------: | ---------------------: | --------------: | +| Shortcut / Action / Spotlight | yes | yes | yes | yes | yes | +| Start Page | yes | yes | yes | yes | yes | +| ChatPanel `+` menu | yes | already visible | yes | yes | yes | +| App sidebar | yes | already visible | yes | yes | yes | + +## Scoped-out layers + +No Rust, database, session initialization, external wire protocol, queue lifecycle, or resolver logic changed. Those skill checklist areas were inspected for applicability and intentionally skipped beyond the explicit Layer 8–10 statements above. diff --git a/docs/architecture-audit-2026-07-12/work-management-github-consolidation.md b/docs/architecture-audit-2026-07-12/work-management-github-consolidation.md new file mode 100644 index 000000000..96b20f1a0 --- /dev/null +++ b/docs/architecture-audit-2026-07-12/work-management-github-consolidation.md @@ -0,0 +1,38 @@ +# Kanban GitHub consolidation architecture audit + +## Acceptance criteria + +- Chat Pane has no Manage Issues action, render branch, navigation command, atom, or surface reducer state. +- GitHub issue/PR ownership lives under `modules/MainApp/WorkManagement`. +- Kanban exposes distinct typed `github-issues` and `github-prs` inner sections. +- Sidebar selection, active content, and the singleton management tab resolve from the same `managementSection` value. +- Repository/options/filters remain visible in a left sub-pane while results render on the right. +- Selecting an issue or PR opens a second-level detail inside the right pane. +- Issues and PRs share one data/filter implementation but fetch only the active scope. +- TypeScript, targeted ESLint, focused tests, and whitespace checks pass. + +## Ten-layer audit + +| Layer | Coverage | Verdict | Evidence / reason | +| ------------------------------------- | ----------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1. Compilation correctness | TypeScript and changed-file lint | pass | `pnpm typecheck` and targeted ESLint complete with zero errors. Rust is untouched. | +| 2. Dead code / structural duplication | Former Chat Pane Manage Issues surface | pass | Removed the Chat Pane lazy render, start-page and plus-menu entries, content-state branch, navigation callback, atom, surface variant, reducer field/case, and adjacent Workstation visibility checks. A global sweep finds zero old symbol references. | +| 3. Naming consistency | Surface and detail ownership | pass | The implementation is now `WorkManagement/GitHubWorkItemsSurface`; Chat-specific detail state/handlers and the Chat-specific storage key were renamed. | +| 4. Semantic overloading | `Manage Issues`, Issues, PRs, Ops section | pass | The generic mixed Chat surface is replaced by explicit `GITHUB_ISSUES` and `GITHUB_PRS` section identities while sharing only implementation, not navigation meaning. | +| 5. Default branches | Ops content selection | pass | Projects, GitHub Issues, and GitHub PRs are explicit branches; the default remains the established Ops task surface. No GitHub section silently falls through to Kanban. | +| 6. Cross-domain leakage | Chat Panel ↔ Kanban | pass | The 2,000-line GitHub implementation moved out of `engines/ChatPanel` into the Kanban module. Shared issue detail and add-to-agent services remain imported at their existing reusable boundaries. | +| 7. New-developer clarity | State and entry points | pass | Sidebar node kinds, home-tab constants, renderer branches, and the surface `scope` prop all name Issues and PRs directly. There is one visible owner. | +| 8. Wire protocol / serialization | GitHub commands and local persistence | pass | Backend GitHub payloads are unchanged. Only the local repository-filter preference key changes to an Ops-owned name; no external serialized contract changes. | +| 9. Init parity | Issues and PRs entry paths | pass | Both sidebar entries call `openKanbanChatPanelTabAtom`, activate the singleton management tab, publish through the Ops header host, resolve repositories identically, and mount the same scoped surface. | +| 10. Resolver symmetry | Section, query, list, detail | pass | The active Ops `managementSection` drives sidebar selection and main content; `scope` initializes the matching search query, filters one item kind, fetches one API family, and resets incompatible detail state on change. | + +## Entry-point and ownership matrix + +| Entry point | Canonical tab | Inner section | Data scope | Detail host | +| --------------------------- | ---------------- | --------------- | ----------- | ------------------ | +| Ops sidebar → GitHub Issues | singleton Kanban | `github-issues` | issues only | right results pane | +| Ops sidebar → GitHub PRs | singleton Kanban | `github-prs` | PRs only | right results pane | + +## Scoped-out layers + +No Rust, database schema, GitHub command payload, authentication, issue mutation semantics, session dispatch, or queue lifecycle behavior changed. The existing translation strings remain reusable content labels even though the live surface owner moved to Kanban. diff --git a/docs/architecture-audit-2026-07-13/AgentOrgModernFamilyRecovery.md b/docs/architecture-audit-2026-07-13/AgentOrgModernFamilyRecovery.md new file mode 100644 index 000000000..58214e46e --- /dev/null +++ b/docs/architecture-audit-2026-07-13/AgentOrgModernFamilyRecovery.md @@ -0,0 +1,82 @@ +# Agent Org “Modern Family” 五批修复架构审计 + +日期:2026-07-13 +分支:`fix/issue-272-agent-org-recovery-invariants` +范围:用户实测中出现的根 Coordinator 错误进入 Plan、任务链创建不完整、消息绕过任务、Reviewer 尚在运行却提前收尾、暂停与空筛选红错。 +结论:五批修复已接入生产工具装配和持久化路径;本范围没有遗留的 P0/P1 问题。审计额外发现并修复了“任务图检查与落库之间可能插入一张并发任务”的窄竞态。 + +## 验收标准与结果 + +| 用户看到的问题 | 必须成立的新行为 | 结果 | +| ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | ---- | +| Group chat 要求用户切 Plan mode | 活跃 Agent Org 的根 Coordinator 不暴露通用 mode-switch 工具;计划由带 `execution_mode=plan` 的成员任务承载 | 通过 | +| Plan / 写作 / Review 的卡片对不上 | Coordinator 可用一个 `task_graph_create` 原子写入完整动态依赖图;失败时零任务落库 | 通过 | +| 创建 Review task 失败后,Coordinator 仍用聊天叫 Reviewer 干活 | 发给 Worker 的正式 plain 消息必须绑定真实、未完成、依赖已就绪且对收件人有权限的 `related_task_id` | 通过 | +| Reviewer 还在 Running,Coordinator 已宣布全部完成 | `task_list.run_summary.completion_ready` 同时检查 Task、Session、Inbox、Turn intent、Intervention 和 Plan approval | 通过 | +| `status=""` 形成红色工具失败;Pause 误伤未启动成员 | 空 status 当作未筛选;`OrgPause` 不把没有 live runtime 的惰性成员修成 Failed | 通过 | + +## 十层架构审计 + +| Layer | Line / Element | Verdict | Reason | Suggested change | +| ---------------- | ---------------------------------------------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- | +| 1 编译与验证 | Rust / TypeScript / task tests | keep | `cargo check -p org2`、typecheck、格式和 diff 检查通过;任务工具 58/58、task store 43/43、消息 14/14 通过。 | 无。 | +| 1 编译与验证 | `cargo test -p agent_core --lib` | keep with reason | 2995/2997 通过;剩余 skill-content 与 search-tool 两项是本分支之前已存在且与 Agent Org 无关的基线。 | 另开基线清理,不混入本修复。 | +| 1 编译与验证 | strict Clippy | keep with reason | 新增任务图、消息门和完成证书没有产生 Clippy 诊断;全 crate 仍被 Provider、Memory、Channel 等既有告警阻塞。 | 另开 Clippy cleanup PR。 | +| 2 活代码/重复 | `task_dependency_closure` | fixed | 单任务、批量任务和 transaction-time recheck 曾各自可能定义“依赖覆盖”;现共享一个 closure 算法。 | 无。 | +| 2 活代码/重复 | `task_graph_create` production/debug/test wiring | keep | 工具已接到 builtin metadata、policy、production overlay、debug runtime、test API、Rust/TS tool names 与前端事件识别,不是只在测试里存在。 | 无。 | +| 3 命名 | `TaskGraphCreate` / `related_task_id` / `completion_ready` | keep | 名称分别表示“原子任务图”“消息所属任务”“完成证书”,没有用含糊的 `state` 或 `done`。 | 无。 | +| 4 语义维度 | Run / Session / Task / Delivery / Approval | keep | Run 是否继续、成员是否正在工作、任务是否完成、消息是否已送达、计划是否获批分别保存;不再用 `open=0` 猜整个组织完成。 | 无。 | +| 5 FSM 完整性 | `session_is_quiescent_for_completed_run` | keep | 每个 `SessionStatus` 显式分类;Running、Pending、Waiting、Paused 都会阻止完成证书。 | 新增状态时继续让编译器强制补齐 match。 | +| 5 FSM 完整性 | task dispatch | keep | 只有依赖已完成的根任务收到 assignment;下游由真实 TaskCompleted 事件解锁。 | 无。 | +| 6 跨域边界 | root Plan vs member Plan task | fixed | 通用 root mode switch 不再参与活跃 Agent Org 编排;用户显式以 Plan 启动普通 root session 的能力仍保留。 | 无。 | +| 6 跨域边界 | Chat routing vs task authority | fixed | Hierarchy Mode 仍决定“谁能联系谁”;Task authority 决定“谁能给谁派活”。可聊天不等于可绕过任务。 | 无。 | +| 7 新开发者理解 | Coordinator prompt / tool descriptions | fixed | Prompt 解释原子图、动态依赖、task-bound message、Plan task 和 completion certificate;非代码写作不会因工具存在就误入 GitHub issue workflow。 | 后续修改继续保留字符串契约测试。 | +| 8 Wire / schema | Rust + TS `TASK_GRAPH_CREATE` | keep | 工具名、provider JSON schema、event extraction 与前端识别一致;schema compatibility 测试通过。 | 无。 | +| 8 Wire / schema | recoverable guidance | keep | 依赖遗漏、plain 消息缺 task、空 after-dependencies 等返回结构化 guidance,不制造用户可见的红色失败卡。 | 长期可由 UI 渲染成专用提示卡。 | +| 9 初始化一致性 | production overlay / debug runtime / test API | keep | Coordinator 才注册跨成员 graph tool;所有测试与调试入口使用同一实现。 | 无。 | +| 10 Resolver 对称 | member owner / eligibility / recipient | keep | owner、eligible member 与 message recipient 都从 run roster 的稳定 member_id 解析;不接受 display name 猜测。 | 无。 | + +## 审计过程中额外修复的竞态 + +原本工具会先读取当前开放任务,再决定新图是否需要依赖它们,然后进入 transaction 插入新图。极窄情况下,另一条执行流可以刚好在两步之间新增任务:新图本身仍是完整的,但它会漏掉刚出现的开放工作。 + +现在 store 在真正写入的同一笔 SQLite IMMEDIATE transaction 里再检查一次: + +```mermaid +flowchart LR + A["工具预检查\n给模型友好 guidance"] --> B["取得统一 writer lock"] + B --> C["IMMEDIATE transaction\n重读现有 open tasks"] + C -->|"依赖覆盖完整"| D["验证整张图与环"] + D --> E["一次写入 tasks + history"] + C -->|"发现并发新增的遗漏任务"| F["整笔回滚\n返回重新确认 guidance"] +``` + +回归测试确认:事务内发现遗漏时,数据库只保留原来的任务,不会留下半张新图。 + +## 最终流程 + +```mermaid +flowchart TD + U["用户在 Group chat 提交目标"] --> C["Coordinator 保持 Build\n设计动态工作图"] + C --> G["task_graph_create\n一次写入 Plan / Write / Review / Final 等实际需要的节点"] + G --> R["只唤醒依赖已满足的根任务"] + R --> M["Member 完成自己的 Task\n写 durable output"] + M --> N["后端解锁真正依赖它的下游 Task"] + N --> M + M --> S["Coordinator 调用 task_list"] + S --> Q{"completion_ready?"} + Q -->|"false"| W["按 blocker 等待真实事件\n不假完成、不空 Wake"] + W --> S + Q -->|"true"| F["向用户输出最终结果"] +``` + +## 明确保留的边界 + +1. 依赖链不是写死的 `Planner → Implementer → Reviewer → Tester`。Coordinator 根据本次请求动态生成;无依赖的任务可以并行,消费上游产物的任务必须声明依赖。 +2. `HierarchyMode::Soft` 没有被删除。它管理通信可达性,不授予跨成员任务修改权。 +3. `completion_ready` 是给 Coordinator 的持久化完成证书,不是新的大模型,也不取代 Coordinator 的判断和最终表达。 +4. 本报告不背书当前 dirty worktree 中 Git、Key Vault、Provider、其他 UI 等无关改动;本轮没有修改 `*.tsx`,因此没有新增 frontend-ui-audit 报告。 + +## 最终判断 + +这五批把 Agent Org 从“模型靠聊天和局部看板猜下一步”收紧为“Coordinator 设计任务图,数据库保证任务与依赖,消息只能补充上下文,完成必须拿到多维证书”。Modern Family 实测暴露的三条错误捷径——根 Plan、消息绕任务、Reviewer 未完先收尾——都已在代码边界被阻断,而不是只靠提示词劝模型别犯错。 diff --git a/docs/architecture-audit-2026-07-13/AgentOrgPlannerApproval.md b/docs/architecture-audit-2026-07-13/AgentOrgPlannerApproval.md new file mode 100644 index 000000000..ffbeac8b0 --- /dev/null +++ b/docs/architecture-audit-2026-07-13/AgentOrgPlannerApproval.md @@ -0,0 +1,89 @@ +# Agent Org Planner、审批与 Wake 加固架构审计 + +日期:2026-07-13 +范围:#272 恢复链路,以及本轮五批 Planner 任务、动态依赖、审批、Wake 和 Group chat 状态投影。 +结论:本范围内没有遗留的 P0/P1 架构问题;审计发现的三组“数据库写了一半”风险已经改成单事务。工作区中其余 Git、Key Vault、Provider 等改动不属于本报告,也没有被本轮修改或背书。 + +## 验收结果 + +| 检查 | 结果 | 说明 | +| ----------------------- | --------------------------- | -------------------------------------------------------------------------------------- | +| Rust 编译 | 通过 | `cargo check -p org2` | +| TypeScript | 通过 | `npm run typecheck` | +| Agent Org approval 单测 | 11/11 | 包含审批、退回、编辑、暂停、自动审批与事务回滚 | +| `create_plan` 单测 | 7/7 | 包含空标题、空内容、错误 session 与动态说明 | +| Agent Org 真实桌面 E2E | 通过 | task-driven Plan → 审批 → 下游解锁 | +| `agent_core` 全量单测 | 2984 通过,2 个既有基线失败 | 沙箱外运行后端口测试正常;剩余两项是无关的 skill 内容与 search 参数冲突基线 | +| Clippy | 项目基线未清零 | 全依赖被既有 dependency warning 阻塞;`--no-deps` 有既有警告。本轮新增的两条警告已修掉 | +| Diff 空白检查 | 通过 | `git diff --check` | + +## 十层审计 + +| Layer | Line / Element | Verdict | Reason | Suggested change | +| -------------------- | --------------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------- | +| 1 编译 | `src-tauri` / frontend | keep with reason | Rust 与 TypeScript 均编译;本轮新增代码没有留下项目 warning。全仓 Clippy 仍受既有基线影响。 | 本 PR 不顺手清理无关 Clippy 基线,单独建 cleanup PR。 | +| 2 死代码与重复 | `agent_org_plan_approvals.rs:256,286,391` | fixed | Coordinator 请求、自动批准、退回反馈原先存在各写各的风险;现在共享事务 helper。 | 无。 | +| 2 死代码与重复 | `agent_inbox.rs:714` | fixed | 新增 `insert_in_tx`,审批和 Inbox 不再各开一个 transaction。 | 无。 | +| 2 死代码与重复 | legacy `ExecModeSetRequest` reader | keep with reason | 当前生产端不再生成这种消息,但仍需读取旧数据库历史行;保留的是反序列化兼容,不是第二套调度机制。 | 等明确放弃旧本地 DB 兼容时再删除。 | +| 3 命名 | `agent_org_tasks::TaskExecutionMode` | keep | 名字明确表示“这张任务卡下一次应以 Plan 还是 Build 执行”,不会和整个 session 的通用执行模式混淆。 | 无。 | +| 3 命名 | `AgentOrgPlanInboxDelivery` | keep | 类型明确表达“审批状态与哪封持久信件一起提交”,没有用模糊的 `Result`/`Context`。 | 无。 | +| 4 语义维度 | Run / Session / Task / Approval / Delivery | keep | 五个状态分开保存:run 是否继续、session 是否工作、task 是否完成、plan 是否获批、inbox/wake 是否送达,互不冒充。 | 继续要求新 UI 标明自己显示的是哪一维。 | +| 4 语义维度 | `org_tasks.rs:118` `AwaitingPlanApproval` | keep | 这是从 durable approval 投影出的界面阶段,不会把 Run 改成 Paused,也不会把 Task 假装 Completed。 | 无。 | +| 5 默认分支 | `PlanApprovalPolicy` | keep | `Coordinator`、`User`、`Automatic` 三种策略在创建与提示词中显式匹配;没有 `_ => automatic` 之类危险兜底。 | 新增策略时让编译器强制所有 match 补齐。 | +| 5 默认分支 | historical missing `execution_mode` | keep with reason | 旧 task / inbox 行缺字段时按 Build 读取,只用于历史兼容;新 `task_create` 强制显式传值。 | 保持“旧读宽松、新写严格”。 | +| 6 跨域泄漏 | Agent Org approval vs top-level Plan approval | keep | 两者没有复用同一状态:单 agent 的 Build/Skip 机制保持原样;Agent Org 审批绑定 run + member + source task。 | 无。 | +| 7 新开发者理解 | `create_plan.rs:168` tool description | fixed | 工具说明现在写清:必须绑定 owned in-progress Plan task;提交后停止;批准负责完成任务;不会制造 fake Build turn。 | 无。 | +| 7 新开发者理解 | `section_builders.rs:680-768` | fixed | Coordinator 的 prompt 同时解释 dynamic dependency、dispatch policy、execution mode 和审批结果。 | 后续提示词变更继续有字符串契约测试。 | +| 8 Wire/序列化 | `TaskAssigned.execution_mode` | keep | 扁平 typed field,历史缺失默认 Build;成员从真实 assignment 决定 Plan/Build。 | 无。 | +| 8 Wire/序列化 | `task_create` schema | fixed | `dispatch_policy` 和 `execution_mode` 都是 required;依赖确认返回结构化 guidance,不制造 trajectory-visible tool failure。 | 无。 | +| 8 Wire/序列化 | Plan 内容边界 | keep | 审批保存完整 markdown 与 artifact path;注入 Inbox/TaskOutput 的文本有总量边界,避免无界 prompt 膨胀。 | 后续可加 payload-size telemetry,但不是正确性前提。 | +| 9 Init parity | setup / test env / debug Agent Org fixtures | fixed | 新审批表在 production setup、测试 DB 与 E2E 初始化一致;遗漏的 `plan_approval_policy` fixture 已补齐,`cargo check -p org2` 能抓住。 | 新增 OrgDefinition 字段时继续编译所有 desktop/E2E target。 | +| 9 Init parity | `external_import/tests.rs` 全局 HOME guard | fixed | 全量并行测试曾能在 lifecycle 建表过程中切走 `ORGII_HOME`,造成“no such table”假失败;所有本地 HOME guard 现先取得 workspace canonical lock。 | 新增 env-mutating test 必须使用 `test_helpers::test_env`。 | +| 10 Resolver symmetry | owner / member / task / execution mode | keep | `create_plan` 从 runtime member identity 找到同 run 的 owned in-progress Plan task;ownerless task 不参与 mode prepeek,也不会由成员自领。 | 无。 | +| 10 Resolver symmetry | explicit assignment | fixed | Watchdog、resume、Inbox drain 和 task side effect 都把 ownerless 解释为“等待 Coordinator 指派”;只有真实 `TaskAssigned` 决定 Worker 的下一次执行模式。 | 无。 | + +## 事务审计发现并已修复的问题 + +| 问题类 | 旧风险 | 现在 | +| -------------------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------------- | +| 退回计划 | approval 先变 `changes_requested`,写 Planner Inbox 失败后 Planner 永远收不到意见 | approval 状态与反馈信同一 SQLite transaction;任一步失败全部回滚 | +| Coordinator 审批请求 | 可能有 pending approval,却没有给 Coordinator 的请求信 | approval revision 与请求信同事务创建 | +| Automatic 审批 | 可能 approval 已 approved,但 source Plan task 仍 in_progress | approval 创建、批准、TaskOutput 与 Plan task 完成在同一 transaction | + +对应测试会主动删除 `agent_inbox` 表制造失败,确认数据库不会留下“半成功”状态。这不是只测 happy path。 + +## 状态与事件边界 + +```mermaid +flowchart LR + T["Plan Task\nin_progress"] --> P["create_plan\n写 approval revision"] + P --> C{"审批策略"} + C -->|Coordinator| I["给 Coordinator 一封持久请求信"] + C -->|User| U["Group chat 显示审批卡"] + C -->|Automatic| A["同事务自动批准"] + I --> D{"批准或退回"} + U --> D + D -->|退回| F["同事务保留 Task + 写 Planner 反馈信"] + F --> T + D -->|批准| O["同事务写 TaskOutput + 完成 Plan Task"] + A --> O + O --> N["只解锁真实依赖它的下游任务"] +``` + +## Wake 审计结论 + +- 保留 #272 的恢复能力:未读 Inbox、刚解锁且已有 owner 的任务、审批反馈和受预算控制的真实恢复输入仍可触发 Wake;ownerless task 只提醒 Coordinator 明确指派。 +- 删除“只因还有一个未完成任务就每分钟叫醒”的假输入。 +- 等待用户审批时没有可消费输入,所以不调用模型、不闪烁、不消耗 recovery attempt。 +- Coordinator 或 member 真有未读持久信时仍会 Wake;Wake 只是门铃,Inbox row 才是事实。 +- 同一 member 的并发 Wake 使用确定性 key 合并;只有 Scheduler 真开始 turn 才把 session 写成 Running。 + +## 保留项与明确边界 + +1. SQLite 与计划 markdown 文件不能形成跨文件系统的真正原子 transaction。正常写文件失败会报错并保持 DB 未批准;进程在极窄窗口硬崩溃时,DB 中保存的 `plan_content` 仍是权威内容,文件可重建。这是已记录的边界,不是双真相。 +2. 历史 `ExecModeSetRequest` 只读兼容暂留;新代码没有 producer。 +3. 此报告只审计 Agent Org 范围;当前 worktree 的无关用户改动没有被格式化、删除或纳入结论。 + +## 最终判断 + +这轮设计已经从“Coordinator 用消息临时命令成员切模式”变为“任务本身携带执行契约”。Planner 的 Plan 模式、审批等待、退回反馈、批准完成和依赖解锁都有持久状态与事务边界;Wake 只负责把真实事件送进 turn,不再负责猜测工作。范围内没有需要阻止提交的审计发现。 diff --git a/docs/architecture-audit-2026-07-13/AppUpdater.md b/docs/architecture-audit-2026-07-13/AppUpdater.md new file mode 100644 index 000000000..3ab8b09de --- /dev/null +++ b/docs/architecture-audit-2026-07-13/AppUpdater.md @@ -0,0 +1,92 @@ +# Architecture Audit — AppUpdater + +**Scope:** `src/scaffold/AppUpdater/`, `general.autoUpdateEnabled`, and the General settings entry +**Date:** 2026-07-13 +**Auditor:** Codex + +## Acceptance criteria + +- [x] One lifecycle owner for check, download, and install state +- [x] One scheduler for startup, interval, foreground, and online triggers +- [x] Automatic updates default on and can be disabled through persisted settings +- [x] Startup can install and relaunch; active-use checks silently pre-download +- [x] Manual checks and installs remain available when automation is disabled +- [x] Check throttling, force bypass, coalescing, cache failure semantics, progress throttling, install deduplication, and scheduler cleanup are tested +- [x] Targeted ESLint and updater Vitest suite pass + +## 10-layer audit + +### Layer 1 — Compilation correctness + +- Targeted ESLint passes for every changed TypeScript/TSX file. +- Updater lifecycle tests pass (12/12); the settings-default test also passes. +- Full `tsc --noEmit` reaches one pre-existing error at `src/engines/ChatPanel/InputArea/components/ContextInfoButton.tsx:468` (`string | undefined` passed where `string` is required); no updater diagnostic is emitted. +- Settings UI parity remains red on the `develop` baseline because eight existing `housekeeper.*` keys are not covered by the manifest. The new `general.autoUpdateEnabled` key is covered by the General-section prefix. + +### Layer 2 — Dead code and structural deduplication + +- Traced automatic entry point: `AppDeferredServices` → `AppUpdater` → `AppUpdaterScheduler` → `runAutomaticUpdate` → `AppUpdaterCoordinator`. +- Traced manual entry points from Settings, Spotlight/ActionSystem, Sidebar, and ChatPanel. +- Removed parallel module-level throttle/check/install state. Jotai atoms are projections of coordinator state. +- Production updater calls now have one owner: `check`, `download`, `install`, and `downloadAndInstall` are invoked only by the coordinator. + +### Layer 3 — Naming consistency + +- `autoUpdateEnabled` consistently means the persisted user preference. +- `AutomaticUpdateReason` distinguishes startup, interval, foreground, and online scheduling. +- Documentation now matches the options-object API and current two-hour interval. + +### Layer 4 — Semantic overloading + +| Term | Meaning | Verdict | +| ---------------- | ---------------------------- | -------------------------------------------------------------------- | +| update | Tauri `Update` resource | Keep; concrete external type | +| automatic update | Configured scheduling policy | Keep; represented by `autoUpdateEnabled` and `AutomaticUpdateReason` | +| install | Apply a downloaded package | Keep; distinct from download and relaunch phases | + +No conflicting domain meanings remain inside the updater module. + +### Layer 5 — Default branch analysis + +- `general.autoUpdateEnabled` defaults to `true` in the canonical settings registry and the derived atom also uses `true` as a defensive fallback. +- Public check defaults remain silent and throttled (`notify: false`, `force: false`). +- Download-event handling is exhaustive over Tauri's `Started | Progress | Finished` union; there is no unsafe catch-all branch. + +### Layer 6 — Cross-domain leakage + +- Scheduling and updater resources remain under `scaffold/AppUpdater`. +- The platform settings atom only adapts the central settings domain; it does not own updater lifecycle state. +- General Settings only binds the preference to existing design-system controls. + +### Layer 7 — New-developer confusion test + +- Coordinator, scheduler, and settings preference have separate purpose-based names. +- Comments explain why active-use automation downloads without installing: Windows installation can terminate the app. +- The state model and entry points are documented in the component guide. + +### Layer 8 — Wire protocol and serialization + +- The only persisted wire value is `general.autoUpdateEnabled: boolean`; it is validated by the canonical Zod registry and written through `updateSettingAtom`. +- The updater release request and signed package protocol remain owned by pinned `@tauri-apps/plugin-updater` 2.9.0; this change adds no custom payload or schema generation. +- Tauri's `Update` resource is closed when replaced or explicitly cleared to avoid stale native resources. + +### Layer 9 — Init parity + +| Entry point | Checks setting | Fresh check | Throttle/dedupe | Download | Install | Relaunch | +| ------------------- | -------------: | -----------------: | --------------: | --------: | ------: | -------: | +| Startup automatic | Yes | Yes | Yes | Yes | Yes | Yes | +| Two-hour interval | Yes | Yes | Yes | Yes | No | No | +| Foreground / online | Yes | If throttle allows | Yes | Yes | No | No | +| Manual check | No | Yes | Yes | No | No | No | +| Manual install | No | If cache empty | Yes | If needed | Yes | Yes | + +Manual paths intentionally ignore the automation preference so users can update on demand after disabling background behavior. + +### Layer 10 — Resolver symmetry + +No multi-field fallback resolver was introduced. Both schema default and atom fallback resolve the single automatic-update preference to `true`; there is no asymmetric source chain. + +## Systematic sweep + +- Swept all updater imports and call sites with `checkForAppUpdates`, `installAvailableAppUpdate`, `useAvailableAppUpdate`, `check`, `download`, `install`, and `downloadAndInstall`. +- No second production scheduler, updater transport caller, or install-state writer remains. diff --git a/docs/architecture-audit-2026-07-13/GeminiCliRemoval.md b/docs/architecture-audit-2026-07-13/GeminiCliRemoval.md new file mode 100644 index 000000000..037dc86bf --- /dev/null +++ b/docs/architecture-audit-2026-07-13/GeminiCliRemoval.md @@ -0,0 +1,62 @@ +# Architecture audit: Gemini CLI removal + +## Acceptance criteria + +- Gemini CLI is not registered, detected, installed, launched, parsed, proxied, authenticated, imported, or displayed. +- Gemini CLI-specific Code Assist OAuth and native-provider code is deleted. +- Gemini API-key support remains available as `gemini_api`. +- Antigravity remains a separate provider and uses only its documented CLI, + authentication, configuration, and migration contracts. +- A persisted legacy `gemini_cli` credential cannot corrupt the key vault and is discarded on the next vault write. + +## Ten-layer audit + +| Layer | Coverage | Result | +| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| 1. Compilation correctness | Rust workspace consumers, frontend types, locale JSON, E2E JavaScript syntax | Pass. Affected Rust crates and the application compile; TypeScript reports no errors. | +| 2. Dead code and structural deduplication | Detectors, OAuth adapter, native Code Assist provider, parser, runner setup, proxy routes, commands, UI setup flows | Removed rather than left behind as unreachable branches. Antigravity uses a separate minimal plain-text parser. | +| 3. Naming consistency | `ModelType`, CLI registry, binary IDs, validation schemas, icons, translations, tests | Active Gemini CLI names are gone. The sole runtime string is a tombstone used only to retire old persisted credentials. | +| 4. Semantic overloading | Gemini CLI OAuth versus Gemini API-key provider and Antigravity | Separated. `gemini_api` remains an API provider; Antigravity uses its own keyring-backed CLI contract. | +| 5. Default branch analysis | enum matches, provider fallback, parser dispatch, auto-detect dispatch, setup routing | No default branch silently routes a removed Gemini CLI value to another provider. | +| 6. Cross-domain concept leakage | key vault, runner, storage housekeeping, external import, skill discovery, UI, E2E | Removed Gemini CLI assumptions from every affected domain. | +| 7. New-developer confusion | registry and setup surfaces | There is now one supported Gemini concept in product code: the Gemini API provider. Historical changelog entries remain historical. | +| 8. Wire protocol and serialization | Rust enum, TypeScript schemas, RPC commands, persisted credentials | Removed the live wire value. Added a read-time tombstone filter so old rows are ignored and cleaned up safely. | +| 9. Init parity | main sessions, side queries, fallback providers, goal loop, post-turn processing, subagents | Removed the Code Assist session-id/project initialization path from all provider construction entry points. | +| 10. Resolver symmetry | CLI registry ↔ binary resolver ↔ launch profile ↔ command builder ↔ parser | Gemini CLI was removed from every resolver stage. Antigravity resolves symmetrically to `agy --print` and its plain-text parser. | + +## Systematic sweeps + +| Sweep | Verdict | Notes | +| ----------------------------------------------- | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GeminiCli` / `gemini_cli` | Pass | Remaining occurrences are limited to the persisted-data retirement filter and its test. | +| `gemini-cli` / “Gemini CLI” | Pass | Remaining product occurrences are historical changelog text only. | +| `.gemini`, Gemini OAuth/token environment names | Pass | Removed from active detection, auth, storage, skill discovery, and setup code. | +| Gemini API | Keep with reason | API-key provider support is explicitly outside the removal scope. | +| Antigravity | Adapt independently | Uses `agy`, self-managed keyring/browser authentication, documented config paths, and plain-text print mode; no Gemini OAuth internals are shared. | + +## Gemini-to-Antigravity transition follow-up + +| Capability | Decision | Reason | +| ------------------------------ | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | +| First-launch account migration | Reuse through `agy` | Antigravity itself detects legacy profiles and migrates active tokens into the OS keyring. ORGII must not copy or persist those tokens. | +| Local and SSH login | Delegate to `agy` | Antigravity owns browser sign-in, remote authorization URLs, authorization codes, logout, and keyring cleanup. | +| Non-interactive prompt | Implement natively | ORGII launches `agy --print `, with documented `--model`, `--add-dir`, and `--conversation` flags. | +| Output parsing | New Antigravity parser | `--print` returns plain text; Gemini CLI's `stream-json` event parser is incompatible. | +| Permission mode | Keep Antigravity flag | Full-permission mode uses the documented `--dangerously-skip-permissions` option. | +| Workspace context | Keep native locations | Antigravity reads `GEMINI.md`, `AGENTS.md`, and workspace `.agents/skills`. | +| Global skills | Add new path | Discover `~/.gemini/antigravity-cli/skills`; do not restore legacy `~/.gemini/skills`. | +| Config discovery | Update paths | Track Antigravity settings, keybindings, and MCP files under the documented `.gemini/antigravity-cli` and `.gemini/config` locations. | +| ACP | Mark unavailable | The published CLI exposes a TUI and print mode, not an ACP transport. | + +## Verification + +- `cargo check -p key_vault -p integrations -p agent_cli -p agent_core -p org2` +- `npm run typecheck` +- Locale JSON validation with `jq` +- Targeted key-store, key-extractor, registry, CLI resolver, and provider-factory tests +- Antigravity command-builder and plain-text parser tests +- Skill-scanner tests for the updated discovery set +- Legacy credential retirement regression test +- E2E JavaScript syntax checks and `git diff --check` + +The broader `agent_core` and `key_vault` suites also ran. Their unrelated pre-existing failures are recorded in the delivery summary; all targeted removal tests pass. diff --git a/docs/architecture-audit-2026-07-14/AgentOrgFinalAudit.md b/docs/architecture-audit-2026-07-14/AgentOrgFinalAudit.md new file mode 100644 index 000000000..ecef4b47b --- /dev/null +++ b/docs/architecture-audit-2026-07-14/AgentOrgFinalAudit.md @@ -0,0 +1,78 @@ +# Agent Org #272 最终全量架构审计 + +日期:2026-07-14 +基线:`develop` +分支:`fix/issue-272-agent-org-recovery-invariants` +范围:从 `develop` 分出后,本分支涉及 Agent Org Run、Task、Inbox、Wake、Watchdog、成员生命周期、Planner 审批、任务权限、桌面端投影和 E2E 的全部差异。 + +## 最终结论 + +本分支范围内没有遗留的 P0/P1/P2 正确性问题,可以提交。最终审计发现并处理了两类收尾问题: + +1. 删除 25 个与 #272 无关的纯格式化或旧 Clippy 修写,避免把 Git、Key Vault、Provider、Session Memory 等无关代码混进提交。 +2. 将本次新增的 `member_view_from_parts` 十参数接口收束为一个 identity 参数对象,消除本分支唯一新增的 Clippy 告警。 + +最终设计坚持:Run、Session、Task、Approval、Inbox/Delivery 是五套不同状态;Coordinator 负责明确派工,Worker 不能自动抢 ownerless task;Watchdog 只恢复真实可处理事件;Plan 审批是持久状态,不靠空 Wake 或模型猜测推进。 + +## 十层审计 + +| Layer | Line / Element | Verdict | Reason | Suggested change | +| ------------------ | ------------------------------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- | +| 1 编译与警告 | Rust desktop / E2E / frontend | keep with reason | `org2`、`e2e-test`、TypeScript、ESLint 均通过;本分支新增 Clippy 告警为 0。全仓严格 Clippy 仍有 develop 基线告警。 | 基线告警另开 cleanup PR,不能混入 #272。 | +| 2 死代码与重复 | auto-claim、`find_available`、旧 blocker helpers | fixed | Worker 自动抢 ownerless task 的旧入口和失去调用者的 helper 已清除;全仓 sweep 没有第二套 auto-claim。 | 无。 | +| 3 命名与职责 | `RecoveryPlan` / analyzer / executor / wake outcome | keep | analyzer 只读并给出计划,executor 执行副作用,outcome 明确区分 queued、coalesced、paused、terminal、no-work。 | 无。 | +| 4 状态维度 | Run / Session / Task / Approval / Delivery | keep | 各自持久化、各自转换;Idle 不代表 Run 结束,Pending 不代表可抢,Wake 不代表消息已消费。 | 新功能继续避免用一个状态推断另一个状态。 | +| 5 FSM 与默认分支 | Session status / approval policy / run finality | keep | 关键状态显式分类;终态、Paused、等待审批和历史兼容路径都有明确语义,没有危险的“其他都继续”。 | 新增 enum variant 时依赖编译器强制补齐。 | +| 6 跨域边界 | hierarchy message routing vs task authority | fixed | Hierarchy Mode 只控制成员之间能否通信;Task authority 独立,Coordinator 管全局,Worker 只能推进自己的任务。 | 无。 | +| 7 新开发者可理解性 | prompt、tool schema、typed guidance | fixed | Coordinator 的派工、动态依赖、Planner 执行模式、审批责任和 ownerless 处理均写入提示词与 schema;可修正输入返回 guidance 而非红色失败卡。 | 提示词契约继续保留字符串测试。 | +| 8 Wire / 数据边界 | typed inbox payload、task mutation outcome、reserved metadata | fixed | 新写入严格校验 roster、eligibility、owner 和依赖;历史坏数据仍可读并交给 Watchdog 升级;side effect 使用事务内 outcome。 | eligibility join table 可在后续性能 PR 评估。 | +| 9 初始化一致性 | production / test / debug / E2E DB | fixed | recovery budget、plan approval 等表在各初始化路径一致;CLI Agent Org 在创建和 launch preflight 都明确拒绝。 | CLI parity 完整实现前不要重新开放。 | +| 10 Resolver 对称性 | run/member/session/task identity | keep | member id、session id、root session 和 run id 的解析方向一致;任务展示与 Inbox 显示优先使用 member identity,不用共享 agent type 冒充成员。 | 无。 | + +## 关键不变量复核 + +| 不变量 | 结果 | +| --------------------------------------------------------- | ------------------------------------------------------------------------- | +| 非 Running 的 Run 不能再创建、更新、删除、认领或重派 Task | 通过,Task mutation 与 reconcile 共用 writer lock + immediate transaction | +| Run 收尾与并发 Task create 不能同时成功 | 通过,并发测试锁定只有一个可串行化结果 | +| 同一成员的并发 Wake 不得制造多个空 turn | 通过,确定性 idempotency key 合并请求 | +| Wake 只有真正被 scheduler 接受才消耗恢复预算 | 通过,coalesced / rejected / paused 不计 attempt | +| scheduler 真正开始 turn 前 Session 不得伪装 Running | 通过,状态更新已移至实际执行边界 | +| Paused / terminal Run 的排队 Wake 不得复活 provider | 通过,执行前重新读取 Run 并 fail closed | +| Worker 不能自动认领 ownerless task | 通过,ownerless 只通知 Coordinator 明确指派 | +| Worker 不能修改其他成员的任务状态 | 通过,Coordinator 全局权限与 Worker 自有任务权限分离 | +| Worker 输出后忘记完成 Task 不能永久卡住 | 通过,有限自动修正;失败后通知 Coordinator,不做无限 Wake | +| 等待 Plan 审批时不得每分钟 Wake 或闪烁 | 通过,pending approval 是明确 quiet state | +| 失败成员任务重排不能丢 metadata 或制造半写入 | 通过,Task 与 history event 同事务 | +| Task 完成和 dependency unblock 不能重复发 TaskAssigned | 通过,side effect 依赖事务内 mutation outcome | + +## 验证结果 + +| 检查 | 结果 | 说明 | +| ---------------------------------------------------- | ---------------- | ----------------------------------------------------------------- | +| `pnpm typecheck` | 通过 | TypeScript 类型检查无错误 | +| `pnpm run lint` | 通过 | ESLint 无错误 | +| `pnpm run check:circular` | 通过 | 5,077 文件,无循环依赖 | +| 目标前端单测 | 25/25 | Group chat、Kanban、Task outcome | +| 前端全量单测 | 4,071/4,076 | 5 个失败均位于未修改的 develop 基线模块 | +| `cargo check -p org2` | 通过 | 桌面端完整编译 | +| `cargo check -p e2e-test` | 通过 | Rust E2E target 编译 | +| `cargo test -p agent_core --lib -- --test-threads=1` | 2,984/2,986 | 沙箱外运行;剩余 2 个是未修改的 skill-content 与 search-tool 基线 | +| Agent Org / Recovery / Approval / Lifecycle tests | 全部通过 | 包含并发、暂停、恢复、ownerless、审批与事务回滚 | +| changed Rust `rustfmt --check` | 通过 | 只检查本分支修改文件,不递归污染旧基线 | +| changed E2E `node --check` | 通过 | 变更的 `.mjs` 语法通过 | +| `git diff --check` | 通过 | 无空白错误 | +| `cargo clippy --all-targets -- -D warnings` | develop 基线阻断 | 5 个无关 crate 旧告警;本分支未修改这些文件 | +| Agent Core strict Clippy | develop 基线阻断 | 本次唯一新增告警已修;剩余均可在 develop 原代码复现 | + +## 全量测试中明确不属于本分支的失败 + +- 前端:Housekeeper settings manifest、Cursor external-history、planning-meta 旧断言、editUtils null 处理。 +- Rust:内置 `e2e-testing` skill 内容断言、search tool 的 `repo_path` / `repo_paths` 冲突断言。 +- Clippy:`orgtrack-core`、`cursor-bridge-app` 以及 Agent Core 旧模块的既有 lint debt。 + +这些文件没有被本分支修改。审计没有为追求“全绿数字”而把它们顺手改进来,避免扩大 #272 的评审范围。 + +## 提交判断 + +结论:**可提交**。本次范围的架构不变量、生产路径、事务边界、恢复策略、权限模型和 UI 投影均有实现与测试对应;未发现多余的目标外代码或会阻止提交的回归。 diff --git a/docs/architecture-audit-2026-07-14/CanvasSessionState.md b/docs/architecture-audit-2026-07-14/CanvasSessionState.md new file mode 100644 index 000000000..32b26af8b --- /dev/null +++ b/docs/architecture-audit-2026-07-14/CanvasSessionState.md @@ -0,0 +1,89 @@ +# Architecture Audit — Canvas session state + +**Scope:** `canvasPreviewAtom`, `useCanvasForTurn`, and Canvas consumers in Chat, SessionCore, and WorkStation +**Date:** 2026-07-14 +**Auditor:** Codex + +## Acceptance criteria + +- [x] Session matching is defined once for all Canvas-derived UI state. +- [x] Dismiss and clear operations cannot mutate another session's entry. +- [x] Inline card, latest-canvas shortcut, pinned pill, and Simulator-open state have explicit ownership rules. +- [x] The compatibility hook and duplicated test-only implementations are removed. +- [x] Production consumers use the canonical hook or the small store-level transition helpers. +- [x] TypeScript, targeted ESLint, and Canvas lifecycle tests pass. + +## 10-layer audit + +### Layer 1 — Compilation correctness + +- `pnpm typecheck` passes. +- Targeted ESLint passes for all changed Canvas TypeScript and TSX files. +- Canvas lifecycle and hook suites pass (53 tests). + +### Layer 2 — Dead code and structural deduplication + +- Removed `useCanvasPreviewForSession`, which only relayed a subset of `useCanvasForTurn`. +- Replaced test-local copies of session matching and dismiss behavior with exported pure helpers used by production. +- Swept remaining `canvasPreviewAtom` reads. Direct access remains only in integration owners that write jump/simulator state; Chat, pinned actions, and the WorkStation renderer use the canonical session-scoped hook. + +### Layer 3 — Naming consistency + +- `latestPayload` means the newest matching payload even after dismissal. +- `payload` means the payload still eligible for inline rendering. +- `isDismissed`, `openedInSimulator`, and `allowsLatestCanvasShortcut` name distinct UI decisions instead of overloading one visibility flag. + +### Layer 4 — Semantic overloading + +| Term | Meaning | Verdict | +| --------------- | ---------------------------------------- | ------------------------------------------------- | +| latest payload | Matching session's stored Canvas payload | Keep; may remain available after inline dismissal | +| visible payload | Payload eligible for the inline card | Keep as `payload`; derived from dismissal state | +| clear | Remove the matching session's entry | Keep; distinct from soft dismiss | + +No term drives more than one state transition. + +### Layer 5 — Default branch analysis + +- Missing or mismatched session IDs derive an empty snapshot and leave mutations unchanged. +- `allowsLatestCanvasShortcut` defaults to allowed when no matching global entry exists, so another session cannot suppress the current session's event-store fallback. +- Dismiss and clear helpers use explicit guards rather than catch-all mutation branches. + +### Layer 6 — Cross-domain leakage + +- Store-level helpers contain only session matching and immutable transitions. +- The hook owns Chat-specific shortcut eligibility. +- WorkStation tab closure and Simulator jump behavior remain in their existing UI integration layers. + +### Layer 7 — New-developer confusion test + +- The snapshot interface documents the difference between stored, visible, dismissed, and Simulator-open state. +- Callers consume named snapshot fields rather than reconstructing conditions from the raw atom. +- The deleted compatibility shim no longer creates two apparent public APIs for the same state. + +### Layer 8 — Wire protocol and serialization + +- No wire payload or persisted schema changes. Canvas state remains an in-memory Jotai entry containing the existing `CanvasInlinePayload`. + +### Layer 9 — Init and entry-point parity + +| Consumer | Read path | Mutation path | +| ----------------------- | ----------------------------------------- | ------------------------------------ | +| Streaming `ChatVariant` | `useCanvasForTurn().snapshot.payload` | none | +| `ChatView` shortcut | `latestPayload` plus shortcut eligibility | existing Simulator jump action | +| `PinnedActionsBar` | `snapshot.isDismissed` | session-scoped clear | +| WorkStation Canvas tab | `snapshot.latestPayload` | session-scoped clear plus tab close | +| New-turn sync | none | shared session-scoped dismiss helper | + +All entry points use the same session predicate. + +### Layer 10 — Resolver symmetry + +- `latestPayload`, dismissal, and Simulator-open state all resolve from the same matching entry. +- Both mutation helpers apply the same session guard. There is no field-specific fallback chain. + +## Systematic sweep + +- Swept `canvasPreviewAtom`, `useCanvasPreviewForSession`, `cardDismissed`, and `openedInSimulator` usages. +- No remaining compatibility-hook caller or duplicated session-scoped mutation was found. +- The remaining direct atom integrations intentionally own Simulator selection or backend-stream state writes and are not duplicate Chat presentation paths. diff --git a/docs/architecture-audit-2026-07-14/SessionProvenance.md b/docs/architecture-audit-2026-07-14/SessionProvenance.md new file mode 100644 index 000000000..f56231312 --- /dev/null +++ b/docs/architecture-audit-2026-07-14/SessionProvenance.md @@ -0,0 +1,100 @@ +# Architecture Audit: Session Provenance + +Scope: every file changed by the Session Provenance PR, including the extracted +protocol, canonical Orgtrack records, hook CLI and installers, SQLite storage, +historical reconciliation, RPC schemas, Session Blame UI, sidebar reveal, all +locales, documentation, and rendered E2E coverage. Unrelated key-vault changes +were explicitly removed from the final upstream diff. + +| Layer | Area inspected | Verdict | Reason | Suggested change | +| ---------------------------- | ------------------------------------------------------------------------------------------------ | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | +| 1. Compilation | Rust packages, desktop app, TypeScript boundary, E2E specs | keep with reason | `cargo check -p org2` passes. Current focused suites pass: `orgtrack_protocol` 6 unit/integration tests plus its package-boundary test, `agent_cli` 20, `orgtrack_core` 189, app `orgtrack::` 14, and 22 focused frontend tests. Changed frontend files pass ESLint. Full TypeScript check reaches only the pre-existing untouched `ContextInfoButton.tsx:468` error. Strict clippy reaches only five pre-existing warnings outside this PR after all changed-code warnings were removed. | Resolve the repository's unrelated TypeScript and clippy debt separately. | +| 2. Dead code / deduplication | Protocol types, classifiers, provider parsing, reconciliation, RPC and UI types | keep with reason | Removed unused lookup DTOs, stale translations, speculative `Reference` action and `Transcript` capture method, and an unused attribution field. Live hooks and historical import now share `resource_interaction` action/path/patch classification. Historical import calls the existing Claude/Codex/Cursor session list/load paths and normalized `ActivityChunk`; it does not introduce a second transcript parser. | Keep new provider support behind the existing provider readers and shared classifier. | +| 3. Naming | `ResourceInteractionEnvelopeV1`, `SessionActorLifecycleEnvelopeV1`, Session Provenance, locators | keep with reason | Names describe two different facts: resource activity and actor lifecycle. They avoid the ambiguous “file touches” label and do not overload actor, session, transcript, source DB, and Orgtrack store identities. | Add a new version only for an incompatible wire change; do not rename fields in v1. | +| 4. Semantic overloading | Session/root/actor IDs, action, precision, capture method, source/store paths | keep with reason | Canonical session ID, provider session ID, parent session ID, and actor ID remain separate. Action has six supported values; capture method has three. Attribution precision records whether actor ownership was direct, correlated, or session-only. Original provider data locations are read-only source locators; the Orgtrack DB/spool are writable store locators and are never emitted in privacy-filtered envelopes. | Preserve the distinction when the collector is extracted into a standalone package. | +| 5. Default branches | Unknown tools/events, incomplete hook configs, invalid inbox records, missing transcripts | keep with reason | Unknown file-capable tools fail closed rather than fabricating an action. Hook installation checks the complete structural event/matcher set instead of trusting a marker. Invalid inbox files are quarantined. Transcript navigation is enabled only when a real transcript file exists. | Add explicit mappings and fixtures when a provider adds a new tool/event family. | +| 6. Cross-domain leakage | `orgtrack-protocol`, `orgtrack-core`, `agent-cli`, desktop orchestration, frontend | keep with reason | The protocol crate depends only on serialization/schema concerns and contains no Tauri, SQLite, filesystem, ORG2, or provider implementation. `orgtrack-core` owns canonical classification, `agent-cli` owns vendor config/spooling, the desktop app owns persistence/reconciliation, and the frontend consumes projections. | The later cloud/submodule extraction can move these packages without moving My Station UI policy. | +| 7. New-developer test | Module layout, docs, operational boundaries | keep with reason | Live ingestion remains in `session_provenance.rs`; historical scheduling/checkpoints/provider reuse were split into `historical_backfill.rs`. The protocol README, package RFC, and `docs/session-provenance.md` explain storage, privacy, hooks, source/store paths, upgrades, and extraction. The provider E2E is now a focused spec rather than being hidden inside the Diff-tab spec. | Keep E2E responsibilities split by observable product contract. | +| 8. Wire / serialization | Vendor JSON to v1 envelopes to SQLite JSON to Zod RPC | keep with reason | Both resource and actor-lifecycle envelopes are strictly decoded. JSON Schemas enumerate the six actions, outcomes, and precision values. Negative tests reject prompts, commands, output, content, diffs, identities, source DB paths, and store paths. Deterministic observation IDs include capture method and actor so exact and reconciled facts cannot collide. | Add checked-in upstream payload fixtures when providers publish stable fixtures. | +| 9. Initialization parity | Hook subprocess, desktop drain, native events, historical import, test harness | keep with reason | Hook subprocesses validate and atomically spool but never open the desktop DB. Desktop drain validates before persistence. Native events use the same canonical store. Historical import uses durable fingerprints and immediately requeries after terminal backfill. User preference is written before provider mutation, and returned state reflects actual installation; an unselect therefore removes a hook on the normal path and reports per-platform failure honestly. | A standalone collector must pass this same entry-point matrix before cutover. | +| 10. Resolver symmetry | Repo/workspace/file/session/actor resolution and sidebar replay | keep with reason | Live and historical paths share canonical provider prefixes and file-resource resolution. Claude actor IDs resolve to the same child identities produced by existing importers; Codex/Cursor promote transcript origins only after locating a real file. Session Blame folds participants whose effective replay target equals the root into the root aggregate; distinct children exact-load, reveal the correct time group, expand, select, scroll, and replay independently. Provider first-page refreshes preserve exact-loaded child rows, while disabling a provider still removes them. | Keep labels presentation-only; navigation must continue using canonical IDs and transcript proof. | + +## Systematic sweeps + +- Compared every PR path against `upstream/develop`, including generated schemas, + all 13 locale files, Rust package manifests, frontend RPC types, hooks, state, + tests, reports, and E2E documentation. +- Searched canonical session-ID construction across Claude Code, Codex, Cursor, + native ORG2, historical import, SQLite queries, RPC output, and sidebar state. +- Searched all current file-capable event/tool mappings and verified that live and + backfill paths converge on the same classifier. +- Exercised every supported user-level hook configuration and verified install, + remount/readback, uninstall restoration, malformed-config reporting, and + per-platform status behavior. Cursor completeness includes `postToolUse`, + `subagentStart`, and `subagentStop`. +- Removed unrelated key-vault compatibility changes from the final upstream diff + instead of coupling account migration behavior to Session Provenance. + +## Term overloading table + +| Term | Existing meanings found | Resolution | +| -------------------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| interaction outcome | Agent-core response/cancel/timeout state; Orgtrack resource success/failure state | The protocol uses `ResourceInteractionOutcome`; agent-core retains its domain-local `InteractionOutcome`. | +| session ID | Provider session ID; ORG2 canonical root session ID; actor/child session identity | Separate source, canonical, parent, and actor fields; labels are never identity. | +| original DB path | Provider history input; normalized Orgtrack destination | Read-only `SourceLocator` and writable `StoreLocator`; neither appears in an event envelope. | +| actor lifecycle | An agent/subagent starting or stopping | `SessionActorLifecycleEnvelopeV1`; it proves hierarchy and transcript identity but does not pretend that a file interaction occurred. | +| resource interaction | A read/write/search/list/create/delete operation on a normalized resource | `ResourceInteractionEnvelopeV1`; it may point at an actor established by lifecycle data. | + +## Initialization parity matrix + +| Entry point | Normalize provider payload | Validate v1 | Atomic spool | Desktop DB | Shared resolver | +| ------------------------------------ | ------------------------------------------ | --------------------- | ----------------------------------------------- | --------------------------- | --------------- | +| Claude/Codex/Cursor hook subprocess | yes | yes | yes | no | desktop drain | +| Desktop inbox drain | already normalized | yes | consumes published files; rejects invalid files | yes | yes | +| ORG2 native event | typed native record | not a wire round-trip | no | yes | yes | +| Historical transcript reconciliation | existing provider reader + `ActivityChunk` | not a wire event | no | yes; fingerprint checkpoint | yes | +| Protocol golden/schema tests | not applicable | yes | no | no | not applicable | + +## Rendered and live evidence + +The real hook-settings test passed in an isolated home using a real pointer: it +enabled Codex, remounted the settings panel, reread the installed status, then +restored the original state. This verifies that the switch is stateful rather +than visual-only. + +The focused provider E2E passed end to end with the locally authenticated Claude +Code, Codex, and Cursor CLIs. Each provider read and edited the same isolated +file through its real tools; production hooks emitted privacy-filtered records; +the desktop drained them into SQLite; historical backfill reconciled transcripts; +and My Station rendered Session Blame. It proved root and subagent transcript +identity, distinct root/child replay, sidebar group expansion/selection/scroll, +and absence of content sentinels from captured metadata. + +An audit rerun initially exposed a race where a provider first-page refresh +could remove an exact-hydrated Codex child after the transcript had already +opened. The loader now preserves child rows across ordinary page replacement, +but not across provider disable. A focused regression test covers both branches, +and a fresh real-provider E2E then passed the previously failing child +expand/select/scroll assertion. + +The independent native ORG2 Diff scenario was also launched, but its `before +all` stopped before product interaction because the isolated account database +contained no Codex account satisfying `gpt-5.5 + session token + Rust-agent +support`. Claude candidates were absent as well. This is recorded as an +environment/credential fixture blocker, not a passing product assertion and not +a Session Provenance regression. + +## Session Blame to sidebar contract + +| Boundary | Verified behavior | +| ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| RPC | `sessionIds?: string[]` is additive, Zod-validated, and exact-match; `session-1` cannot match `session-10`. | +| Cache | Canonical `session_id` has an idempotent index; explicit historical navigation can hydrate its requested root/child even when presentation filters hide it, without changing the user's source preferences. | +| State | Reveal requests use monotonically increasing IDs, clear conditionally, uncollapse the sidebar, clear search, and open the containing time group. | +| DOM | Canonical session ID is the row identity; scrolling occurs once per reveal request. | +| Transcript | Root and child are independently hydrated and selected; actor navigation is clickable only with a real transcript path. | + +The implementation therefore supports the claimed chain: +`session -> actor/subagent -> resource interaction -> proven transcript replay`. +When a provider does not expose enough evidence, the record remains visible at +session-only precision instead of inventing subagent ownership. diff --git a/docs/architecture-audit-2026-07-14/WarpImportedHistory.md b/docs/architecture-audit-2026-07-14/WarpImportedHistory.md new file mode 100644 index 000000000..e9177a271 --- /dev/null +++ b/docs/architecture-audit-2026-07-14/WarpImportedHistory.md @@ -0,0 +1,40 @@ +# Architecture audit: Warp imported history + +## Acceptance criteria + +- Detect Warp's local database on supported desktop platforms. +- Import list metadata and replayable user, assistant, reasoning, model, and tool events without mutating Warp data. +- Reuse the shared imported-history cache, aggregation, recent-path, sidebar, replay, and source-rescan pipelines. +- Keep Warp Agent history distinct from the separate Warp CLI/TUI integration. + +## Ten-layer audit + +| Layer | Coverage | Result | +| ----------------------------------------- | ---------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1. Compilation correctness | Core crate, Tauri command registration, TypeScript source registry and filters | Targeted Rust and frontend tests pass. The complete `org2` desktop crate compiles. | +| 2. Dead code and structural deduplication | Database discovery, Kanban source mapping, imported cache | One shared Warp path resolver feeds both detection and import. One shared source→Kanban map replaces two former copies. | +| 3. Naming consistency | `warp`, `warpapp-`, `SOURCE_WARP`, command names, icon ID | Source IDs and prefixes are consistent across Rust, Tauri, TypeScript, tests, and docs. `warpapp-` identifies imported app history; it is not a CLI-agent ID. | +| 4. Semantic overloading | Warp conversation IDs, ORGII session IDs, Warp terminal sessions | Source IDs remain raw conversation IDs in cache keys; only ORGII-facing IDs receive `warpapp-`. Terminal restoration data is not presented as Agent conversation history. | +| 5. Default branch analysis | Unknown protobuf messages/tools, missing summary, missing timestamps | Unknown tools preserve raw names/payloads. Missing optional data uses explicit fallback order; missing schema or malformed tasks returns an empty/safe result. | +| 6. Cross-domain concept leakage | Warp parser versus shared import infrastructure | Warp schema/protobuf knowledge is isolated under `sources/warp`; generic cache/query/replay contracts remain source-neutral. | +| 7. New-developer confusion | Module docs, storage note, constants | The storage schema, paths, mappings, fallbacks, privacy limits, and #331 boundary are documented in `docs/architecture/warp-imported-history.md`. | +| 8. Wire protocol and serialization | Official Warp protobuf descriptor, JSON projection, Tauri payloads | Uses Warp's pinned published descriptor rather than a hand-copied proto. Outputs existing `ActivityChunk` and imported-session wire types. Fixture tests exercise protobuf encode/decode and SQLite rows. | +| 9. Init parity | Sidebar list, replay, recent paths, source stats, rescan aggregation, spotlight, Kanban filter | Every existing imported-history entry point has a Warp registration. No separate partial initialization path was introduced. | +| 10. Resolver symmetry | Detect path → source cache → list/replay command → frontend descriptor/filter | The same source ID/prefix/path candidates resolve end to end, with tests for paths, prefix round-trip, registry lookup, replayability, and filter mapping. | + +## Deliberately skipped + +| Area | Reason | +| -------------------------------------------- | ------------------------------------------------------------- | +| Warp CLI process launch and live TUI capture | Out of scope for #366; tracked by #331. | +| Cloud-history API | No local-import contract and would change privacy/auth scope. | +| Mutation or migration of `warp.sqlite` | Imported history is strictly read-only. | + +## Verification + +- Six Rust fixture/schema/path/cache tests under `sources::warp::history::tests`. +- Frontend registry, replayability, session-dispatch, pagination, and Kanban mapping tests. +- `cargo check -p org2`. +- ESLint over every changed TypeScript/TSX file. +- Full TypeScript checking reaches one unrelated pre-existing error in `ContextInfoButton.tsx:468`; no Warp file reports a type error. +- `git diff --check`. diff --git a/docs/architecture-audit-2026-07-15/AgentBlameApiRemoval.md b/docs/architecture-audit-2026-07-15/AgentBlameApiRemoval.md new file mode 100644 index 000000000..e054c7915 --- /dev/null +++ b/docs/architecture-audit-2026-07-15/AgentBlameApiRemoval.md @@ -0,0 +1,49 @@ +# Agent Blame API removal architecture audit + +**Scope:** End-to-end removal of the five RPCs formerly consumed only by `AgentBlamePanelView`: scan start, scan status, scan cancel, index read, and file-session lookup. + +## Acceptance criteria + +- [x] No frontend wrapper, RPC procedure, Zod input/output schema, Tauri registration, or Rust command remains for the five removed APIs. +- [x] No API-only Rust helper, projection type, test, cancel marker, or options relay remains. +- [x] Shared orgtrack export, sync, index generation, and file timeline behavior remains available to its live callers. +- [x] TypeScript typecheck and targeted ESLint pass. +- [x] Rust formatting, library compilation, and focused orgtrack tests pass. + +## Removed call chains + +| Frontend wrapper | Tauri command | Rust/API-only implementation removed | +| ---------------------------- | ------------------------------- | -------------------------------------------------------------------------- | +| `startOrgtrackScan` | `orgtrack_scan_start` | Background scan launcher and public `OrgtrackScanOptions` relay | +| `getOrgtrackScanStatus` | `orgtrack_scan_status` | Scan-progress reader endpoint | +| `cancelOrgtrackScan` | `orgtrack_scan_cancel` | Cancel request helper, cancel-marker path, and cancellation guards | +| `getOrgtrackIndex` | `orgtrack_get_index` | Standalone index reader endpoint | +| `lookupOrgtrackFileSessions` | `orgtrack_lookup_file_sessions` | File-session aggregation projection, projection types, and projection test | + +## Ten-layer audit + +| Layer | Coverage | Verdict | +| ----: | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | Compilation correctness | `tsc --noEmit`, targeted ESLint, `rustfmt --check`, and `cargo check --lib` pass. The focused orgtrack test filter passes 5/5 tests. Strict Clippy was attempted and is blocked by six pre-existing findings in untouched orgtrack-core files; none point to this change. | +| 2 | Dead code and structural deduplication | Traced each frontend entry point through RPC registration and Tauri dispatch. Removed the orphaned background launcher/status/cancel helpers, standalone index reader, file-session projection/types/test, cancel path, and the now-single-caller options relay. | +| 3 | Naming consistency | Repository-wide sweeps return zero hits for all five frontend wrapper names and command strings. Remaining `orgtrack` index/timeline names belong to live export, sync, and editor-timeline flows. | +| 4 | Semantic overloading | `scan` now refers only to synchronous export progress/checkpoint internals; it no longer also denotes a user-controlled background RPC lifecycle. `index` remains the generated repo-sync projection and sync response type. | +| 5 | Default branches | Deleted the API defaults for `resume`, `rebuild`, and trajectory scan start. The remaining synchronous export path directly expresses its established resume behavior instead of routing fixed values through an options struct. No new catch-all branch was added. | +| 6 | Cross-domain leakage | Agent Blame-specific API controls no longer leak into the shared lineage RPC surface or repo-sync public types. Shared orgtrack primitives required by other features were retained. | +| 7 | New-developer clarity | There is no longer a public scan-control API without a UI owner, and no `OrgtrackScanOptions` type suggesting configurable callers that do not exist. | +| 8 | Wire protocol | The five Tauri IPC command registrations and their Zod wire contracts were removed together. No HTTP, WebSocket, or external serialized payload changed. | +| 9 | Initialization parity | The removed commands were query/control endpoints, not app/session initialization entry points. The live `orgtrack_initialize` and `orgtrack_export` entry points still share `export_orgtrack`. | +| 10 | Resolver symmetry | No multi-source resolver or fallback chain is involved in the removed call paths. The surviving export path has one direct tier input and one checkpoint source. | + +## Intentionally retained live surfaces + +- `orgtrack_initialize` and `orgtrack_export` for synchronous metadata export. +- `orgtrack_sync_core_repo` and `OrgtrackIndex` for repo synchronization. +- `orgtrack_get_file_timeline` for editor timeline attribution. +- Internal scan progress/checkpoint structures used while producing exports. + +No compatibility shim or deprecated alias was added for the removed commands. + +## Existing verification debt + +`cargo clippy --lib -- -D warnings` currently fails on six unrelated pre-existing findings in `canonical.rs`, `privacy/mod.rs`, and imported-history source parsers. They were left untouched to keep this removal scoped and avoid mixing an unrelated cleanup into the API deletion. diff --git a/docs/architecture-audit-2026-07-15/SpotlightSelectorContextMenus.md b/docs/architecture-audit-2026-07-15/SpotlightSelectorContextMenus.md new file mode 100644 index 000000000..b6b806679 --- /dev/null +++ b/docs/architecture-audit-2026-07-15/SpotlightSelectorContextMenus.md @@ -0,0 +1,36 @@ +# Architecture Audit: Spotlight Selector Context Menus + +## Acceptance criteria + +- [x] One shared context-menu renderer owns native menu creation and clipboard + failure handling. +- [x] Selector builders declare copy values without importing UI transport. +- [x] Branches expose name only; worktrees and workspace-path rows expose name + and path. +- [x] Filesystem-backed rows expose Reveal through the existing cross-platform + opener API and label resolver; branch rows do not invent a path action. +- [x] Repository paths are normalized at their domain adapter boundary. +- [x] Every new production type/property is consumed by a live call path. + +## Ten-layer review + +| Layer | Coverage | Verdict | +| --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | +| 1. Compilation correctness | Changed files pass ESLint and focused Vitest coverage. The full repository typecheck reports only the unrelated pre-existing `ContextInfoButton.tsx:468` error. | No changed-file error found. | +| 2. Dead code and structural duplication | Traced item builders → `SpotlightItemData.contextMenuCopy` → `SpotlightItemRow` → native Tauri menu → shared `copyText` / `revealItemInDir`. | One live path; no per-palette menu duplication. | +| 3. Naming consistency | `contextMenuCopy.name/path`, `Copy Name`, and `Copy Path` describe the copied values consistently. | Keep. | +| 4. Semantic overloading | Branch name, worktree label, workspace name, and filesystem path remain distinct fields. | Keep. | +| 5. Default-branch analysis | Menu entries are included explicitly only when their corresponding value exists; no catch-all invents a path. | Keep. | +| 6. Cross-domain leakage | Palette builders own domain-to-copy-value mapping; the shared row knows only optional name/path strings. | Keep. | +| 7. New-developer clarity | The item data shows exactly which values a row exposes. Native-menu and clipboard concerns remain centralized. | Keep. | +| 8. Wire protocol | No application wire contract changed. Tauri's existing native-menu, opener, and clipboard APIs are reused. | No custom serialization to audit. | +| 9. Init parity | No initialization path changed; every Spotlight row renderer receives the same optional data contract. | Not applicable beyond call-path trace. | +| 10. Resolver symmetry | No resolver or fallback chain changed. The saved-workspace primary/first-folder choice is one documented single-field rule. | Not applicable. | + +## Call path + +`selector item builder` → `contextMenuCopy` → row `contextmenu` event → native +Tauri menu → shared `copyText` browser/Tauri/textarea fallback chain or +cross-platform `revealItemInDir`. + +No architecture fix candidates remain in the audited scope. diff --git a/docs/architecture-audit-2026-07-15/WorktreePalette.md b/docs/architecture-audit-2026-07-15/WorktreePalette.md new file mode 100644 index 000000000..482279f62 --- /dev/null +++ b/docs/architecture-audit-2026-07-15/WorktreePalette.md @@ -0,0 +1,39 @@ +# Architecture Audit: WorktreePalette CRUD Flow + +## Scope and acceptance criteria + +- `src/scaffold/GlobalSpotlight/palettes/BranchPalette/index.tsx` +- `src/scaffold/GlobalSpotlight/palettes/BranchPalette/types.ts` +- `src/scaffold/GlobalSpotlight/index.tsx` + +- [x] One typed source of truth controls switch/remove mode. +- [x] The embedding shell only mirrors mode for footer presentation. +- [x] Create, remove, refresh, and switch use the existing worktree API/cache + path rather than adding parallel transport logic. +- [x] Main and active worktrees cannot enter the remove action path. +- [x] Changed TypeScript files pass ESLint and Prettier. +- [ ] Repository-wide `tsc --noEmit` is clean; currently blocked by the + unrelated pre-existing `ContextInfoButton.tsx:468` type error. + +## Ten-layer review + +| Layer | Coverage | Verdict | +| --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | +| 1. Compilation correctness | Changed-file ESLint and Prettier pass. Repository typecheck reports only the unrelated `ContextInfoButton.tsx:468` error. | No changed-file issue found. | +| 2. Dead code and structural duplication | Traced `WorktreePalette` → `GlobalSpotlight.handleRemoveWorktree` → existing `removeGitWorktree`, plus `refreshWorktreeMap` for list invalidation. The new mode type and callback are both wired into production. | Keep. No duplicate API path introduced. | +| 3. Naming consistency | `WorktreePaletteMode` uses explicit `switch` and `remove` values; callbacks retain the existing `onRemoveWorktree` contract. | Keep. | +| 4. Semantic overloading | `remove` consistently means removing a linked checkout while preserving its branch; branch deletion remains a separate action and handler. | Keep. | +| 5. Default-branch analysis | The two-state union is handled with explicit equality checks; there is no catch-all that assigns future modes switch semantics. | Keep. | +| 6. Cross-domain leakage | Worktree mode stays within the Worktree palette/types. The outer Spotlight state mirrors it only to choose footer chrome. | Keep. | +| 7. New-developer clarity | Mode, handlers, protected-row filter, and cache refresh names state intent directly. | Keep. | +| 8. Wire protocol | No wire type or payload changed. Removal continues to send the existing `worktree_path`/`force` DELETE payload. | Intentionally skipped payload dump; transport is unchanged. | +| 9. Init parity | No initialization path changed. The only production Worktree palette call site supplies the existing create/select callbacks and the shared remove callback. | Not applicable beyond call-site trace. | +| 10. Resolver symmetry | No multi-field resolver or fallback chain changed. | Not applicable. | + +## Call path + +`WorktreePalette row` → `onRemoveWorktree(skipRefresh)` → +`GlobalSpotlight.handleRemoveWorktree` → `removeGitWorktree` → +`refreshWorktreeMap` → subscribed `useWorktreeEntries` rows. + +No architecture fix candidates remain in the audited scope. diff --git a/docs/architecture-audit-2026-07-15/session-file-metadata.md b/docs/architecture-audit-2026-07-15/session-file-metadata.md new file mode 100644 index 000000000..1afc58655 --- /dev/null +++ b/docs/architecture-audit-2026-07-15/session-file-metadata.md @@ -0,0 +1,121 @@ +# Architecture Audit — Orgtrack Round Metadata + +**Scope:** Issues #387 and #388: per-round resource/development metadata, whole-session edit impact, and Kanban file search. + +## Completion criteria + +- [x] One Orgtrack projector owns per-round read/search/write/create/delete/rename observations. +- [x] The same projector owns modified-file line stats and development artifacts (commits/PRs). +- [x] ORG2, Claude Code, Codex, Cursor, and other normalized providers enter through the same tool metadata boundary. +- [x] `session_turns` is a rebuildable ORG2 read cache, not the semantic owner. +- [x] Historical rows rebuild lazily through a versioned index; existing DBs keep working. +- [x] Session, turn, and actor/execution-thread identities are not conflated. +- [x] The chat footer renders resource observations and edit/development metadata. +- [x] Kanban file search uses the whole-session edit projection without parsing transcripts per keystroke. +- [x] Session Blame pages by complete root-session groups and refreshes from a durable SQLite revision. +- [x] Historical backfill ownership/progress survives restarts without retaining an in-memory job registry. +- [x] The chat loads metadata only for rendered turns and removes atoms when those turns leave the view. + +## Ownership and extraction boundary + +| Layer | Owns | Does not own | +| ------------------------ | ------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | +| `orgtrack-protocol` | Stable action/outcome/envelope vocabulary | Provider payload parsing, SQLite, UI | +| `orgtrack-core` | Provider adapters, resource extraction, `TurnMetadataAccumulator`, Git artifact recognition | ORG2 database paths, Tauri commands, React | +| `session-persistence` | Versioned `session_turns` materialized cache and lazy rebuild | Tool-name constants, provider-specific result parsing, Git semantics | +| app `session_provenance` | stdin/inbox/SQLite/filesystem adapters and actor lifecycle wiring | Round aggregation rules | +| frontend | Validated display and navigation | Raw transcript aggregation | + +Moving Orgtrack to a future repository/submodule therefore requires changing Cargo dependency locations and supplying host adapters; the protocol/projector does not depend on the ORG2 app crate. + +## Incremental memory and freshness model + +| Concern | Durable source of truth | Bounded in-memory state | +| ------------------------ | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| File-history freshness | Per-resource SQLite revision advanced by interaction insert/delete triggers | Current visible page plus one numeric revision; no process-wide history cache | +| Session Blame pagination | SQLite query pages root sessions, then returns all interactions for those roots | 30 root sessions by default (100 hard maximum); children never split from their root | +| Historical backfill | SQLite job row with owner, token, status, progress, error, and update time | One process-owner UUID; transcript batches are released after projection | +| Per-round chat metadata | Versioned `session_turns` rows | Only currently rendered turn atoms; stale/session-unmounted atoms are explicitly removed | +| Live invalidation | Revision remains authoritative across every writer and restart | A payload-free Tauri event accelerates refresh; visible-only 5 s revision probes recover missed events | + +Every inbox consumer emits the same invalidation after a successful drain. This prevents a query from consuming a hook envelope before the periodic drain loop can broadcast it. The event is only a hint: the frontend rechecks the SQLite revision before replacing a page, and an ordering change during “load more” restarts from page zero. + +## Identity semantics + +| Field | Meaning | Source | +| ------------ | ------------------------------------------- | --------------------------------------------------------- | +| `session_id` | Durable conversation/session | Provider canonical session identity | +| `turn_id` | User-message-bounded conversational round | Latest non-synthetic user-message id | +| `actor_id` | Root agent/subagent identity | Hook lifecycle or reconciled actor mapping | +| `thread_id` | Provider execution thread/process dimension | Preserved on normalized events; never reused as `turn_id` | + +Native ORG2 associates completed tool calls with the nearest preceding real user message in the in-memory production event store. Reconciled histories infer the same boundary from normalized `user_message` chunks. A provider thread id may identify an execution lane or subagent and is intentionally not promoted to a conversational round. + +## Provider coverage + +| Capture surface | Providers | Projection path | +| -------------------------- | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | +| Managed hooks | Claude Code, Codex, Cursor, Qwen Code, Factory Droid, Trae, OpenCode, Windsurf, Kimi, Antigravity, ZCode | hook adapter → privacy-safe `ResourceInteractionEnvelopeV1` | +| Imported history | Claude Code, Codex, Cursor, OpenCode, Windsurf, WorkBuddy, Trae, Cline, Warp, ZCode | existing provider loader → normalized `ActivityChunk` → Orgtrack resource projector | +| Native ORG2 | Rust-agent event pipeline | merged production tool event → Orgtrack interaction store; turn cache → `TurnMetadataAccumulator` | +| Cloud collaboration replay | Authorized ORG2 team-session event cache | checkout-safe path remap → normalized `ActivityChunk` → Orgtrack interaction store | + +Hook-only providers gain live provenance immediately. Providers with imported-history loaders also gain lazy historical projection. Adding a future provider means implementing an adapter/loader to the normalized boundary, not adding another turn metadata implementation. + +## Production call-chain trace + +| Entry point | Path | Result | +| ----------------- | ------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | +| Live native tool | production event merge → nearest user-message turn → `persist_native_event_interactions` | Canonical session/turn/actor/resource fact | +| External hook | provider hook → `hook_adapter` → privacy-safe spool → bounded drain | Canonical live resource fact without raw content/query/output | +| Historical round | existing provider loader/event cache → normalized tool metadata → `TurnMetadataAccumulator` | Lazy read/search/edit/Git metadata | +| Cloud replay | authorized event cache → owner/viewer checkout remap → user-message round boundary | Exact-owner resource facts without persisting the owner's path | +| Session aggregate | `load_turn_index` → fold unique modified paths and line totals | Final edit impact and Kanban search input | +| Chat UI | validated RPC → per-turn atom → `TurnMetadataFooter` | Read/search paths, edits, commits, and PRs | +| Open file history | SQLite revision probe → root-session page → Session Blame | Incremental refresh without a resident process-wide cache | + +## Ten-layer audit + +| Layer | Verdict | Evidence / decision | +| ------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1. Compilation correctness | Pass | Rust check and 1,187 Rust tests, TypeScript typecheck, 5,127 Vitest tests, full ESLint, targeted Clippy, and two rendered desktop E2E scenarios passed. | +| 2. Dead code / structural duplication | Pass | Removed the unbounded file-interaction read path and the process-wide backfill `HashMap`; one paged store query, one durable job table, and existing provider loaders remain. | +| 3. Naming consistency | Pass | `TurnMetadata` names the UI/cache projection; `ResourceInteraction` names protocol facts; `modifiedFiles` remains the edit-only review subset. | +| 4. Semantic overloading | Pass | Session, turn, actor, and thread meanings are documented and enforced; the former `thread_id → turn_id` assignment was removed. | +| 5. Default branches | Pass | Malformed JSON is tolerated, unknown tools are skipped, failed writes do not claim modifications, missed events recover through revision polling, and prior-process jobs are reclaimed without racing a live owner. | +| 6. Cross-domain leakage | Pass | Provider/tool/Git semantics live in `orgtrack-core`; `session-persistence` calls one provider-neutral accumulator; filesystem/SQLite concerns remain host adapters. | +| 7. New-developer clarity | Pass | Module docs and the ownership/provider/identity tables identify the one extension point and why the cache is rebuildable. | +| 8. Wire protocol / serialization | Pass | Rust serde camelCase, Zod, and TS interfaces agree on revision/page fields and the optional bounded turn-id request (500 maximum). | +| 9. Init parity | Pass | Fresh and legacy DBs both gain parent identity, revisions, triggers, seeded revision rows, indexes, and durable job storage in safe migration order. | +| 10. Resolver symmetry | Pass | Live hooks, native events, cloud replay, and imported histories converge on Orgtrack rules; provider discovery and transcript parsing continue to reuse existing loaders. | + +## Systematic sweeps + +| Issue class | Sweep | Outcome | +| -------------------------- | ------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------- | +| Duplicate provider parsing | Searched provider loaders, hook adapters, and turn-cache code | Existing loaders/adapters are reused; no new transcript reader was introduced. | +| Duplicate round projection | Searched file/Git accumulators and host tool-name constants | One `TurnMetadataAccumulator` remains in `orgtrack-core`. | +| Identity conflation | Searched `turn_id` assignments from `thread_id` | Native and reconciled paths now derive turns from user-message boundaries. | +| Schema parity | Checked create/ALTER/insert/select/Rust/Zod/TS shapes | All include `resource_interactions_json`; v10 rebuilds historical rows lazily. | +| Localization | Parsed every locale JSON and compared the new feature keys | All 13 locales include read/search/failure labels. | +| Unbounded reads/state | Searched file history queries, backfill registries, and turn atoms | Root pages are bounded, job state is durable, and invisible turn atoms are evicted. | +| Invalidation consumers | Searched every hook-inbox drain call and interaction writer | Every drain broadcasts; native/collaboration writers broadcast; revision remains authoritative. | + +## Final verdict + +No blocking architecture finding remains. Orgtrack owns the reusable protocol, provider-neutral projection, paged store contract, and durable metadata; ORG2 owns host adapters and disposable UI state. A future extraction is repository packaging/versioning plus host wiring, not a domain redesign. Runtime memory no longer grows with indexed session count: persisted history/job/turn data remains on disk and the open surfaces keep bounded pages only. + +## Verification + +| Gate | Result | +| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Rust app tests | Pass: `cargo test --lib --no-fail-fast` — 936 tests | +| Rust crate tests | Pass: `orgtrack_core` 230 tests + `session_persistence` 21 tests | +| Rust compilation | Pass: `cargo check` | +| Rust lint | Pass for changed crates with `cargo clippy ... --all-targets --no-deps -D warnings`; whole workspace remains blocked by unrelated existing lint debt in `integrations`, `system-services`, and `key-vault` | +| Frontend types | Pass: `npm run typecheck` | +| Frontend lint | Pass: `npm run lint` | +| Frontend unit tests | Pass: 444 files / 5,127 tests | +| Session Blame E2E | Pass: isolated macOS Tauri/WebDriver with real Claude Code 2.1.210, Codex 0.144.1, and Cursor Agent 2026.07.09-a3815c0 hooks; live refresh, distinct transcripts, and sidebar reveal verified | +| Round metadata E2E | Pass: isolated macOS Tauri/WebDriver `turn-metadata` scenario against the real Tauri command and SQLite cache | +| Localization | Pass: all 13 session locale JSON files parse and contain the new keys | diff --git a/docs/architecture-audit-2026-07-16/CloudOrgManagementTab.md b/docs/architecture-audit-2026-07-16/CloudOrgManagementTab.md new file mode 100644 index 000000000..63c7b185d --- /dev/null +++ b/docs/architecture-audit-2026-07-16/CloudOrgManagementTab.md @@ -0,0 +1,43 @@ +# Cloud org management tab architecture audit + +Scope: the typed `cloud-org` chat tab, all production entry points that open it, activation/close behavior, and the managed-org switcher that updates its payload. + +## Acceptance criteria + +- Org management never borrows the Launchpad tab identity. +- At most one org-management chat tab exists. +- Switching organizations updates that tab in place. +- Switching away and back restores the selected organization. +- Leaving, deleting, or losing access to the selected organization closes the stale management tab. +- Sidebar management and post-create navigation use the same open/focus atom. +- TypeScript, focused unit tests, lint, and cloud i18n parity pass. + +## Term overloading sweep + +| Term | Meaning in this change | Verdict | +| ------------------ | ----------------------------------------------------- | ----------------------------------------------------------------------------------- | +| Launchpad | The `start-page` tab hosting Work / Manage / Trend | pass — no longer labels cloud organization management | +| Manage ORG | The singleton managed-cloud organization settings tab | pass — one visible identity and one open/focus action | +| Manage | Launchpad's workspace-management inner section | keep with reason — separate from cloud-org settings and still owned by `start-page` | +| Organization / org | The selected managed-cloud organization payload | pass — stored as `ChatPanelSelectedCloudOrg`, not inferred from a title | + +## Ten-layer audit + +| Layer | Area inspected | Verdict | Reason / evidence | Suggested change | +| ----: | ------------------------------------ | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | +| 1 | Compilation correctness | pass | `npm run typecheck`, focused ESLint, and 30 focused Vitest assertions pass. | None. | +| 2 | Dead code and structural duplication | pass | Sidebar manage, cloud-org creation, the in-page switcher, activation, and stale-org close all converge on the canonical chat-tab atoms; no parallel tab constructor remains. | None. | +| 3 | Naming consistency | pass | `cloud-org`, `openCloudOrgManagementInChatPanelTabAtom`, and `closeCloudOrgManagementChatPanelTabAtom` consistently describe tab identity and lifecycle. | None. | +| 4 | Semantic overloading | pass | Launchpad remains only `start-page`; cloud settings render under a distinct `cloud-org` variant titled `Manage ORG`. | None. | +| 5 | Default branches | pass | Tab display and activation explicitly handle `cloud-org`; no catch-all maps it to session, workspace, or Launchpad behavior. | None. | +| 6 | Cross-domain leakage | pass | The generic tab store holds only a typed org identifier; cloud fetching and management remain in `Org2Cloud` / `CloudOrgPanelView`. | None. | +| 7 | New-developer clarity | pass | The tab payload documents restoration semantics, and the open atom documents singleton switching behavior. | None. | +| 8 | Wire protocol / serialization | not applicable | No backend request, RPC payload, or external schema changed. The local tab store already starts fresh on app restart. | None. | +| 9 | Entry-point parity | pass | Sidebar Manage ORG, post-create navigation, and the header org switcher all call `openCloudOrgManagementInChatPanelTabAtom`; leave/delete/roster loss use the matching close action. | None. | +| 10 | Resolver symmetry | pass | The explicit `cloud-org` display branch resolves the localized Manage ORG label, while activation resolves the selected organization from the typed `cloudOrg` payload regardless of entry point. | None. | + +## Systematic sweep + +- Searched every `CHAT_PANEL_SURFACE_KIND.CLOUD_ORG` production navigation site; no UI entry point bypasses the dedicated tab constructor. +- Searched every `ChatPanelTab` type/display/activation branch; the new variant has explicit title, icon, activation, singleton-normalization, and close handling. +- Existing rendered cloud-org specs were updated to click the visible General / Repo scopes / Members tabs before asserting or mutating their content. diff --git a/docs/architecture-audit-2026-07-17/ChatHistory.md b/docs/architecture-audit-2026-07-17/ChatHistory.md new file mode 100644 index 000000000..fee14b610 --- /dev/null +++ b/docs/architecture-audit-2026-07-17/ChatHistory.md @@ -0,0 +1,52 @@ +# ChatHistory architecture audit + +Scope: the `ChatHistory` public component, its extracted projection, navigation, viewport, item-action, planning-indicator, and render-view modules, plus the existing state/search/pagination hooks they compose. + +## Acceptance criteria + +- `ChatHistory/index.tsx` is a small composition boundary rather than a state, derivation, effect, and rendering monolith. +- Public props and default behavior remain compatible. +- Raw history projection, visible turn pagination, navigation, viewport effects, item actions, and JSX each have one clear owner. +- The extracted hooks are used by the production entry point and do not create circular imports. +- Tail-turn collapse and conversation-page mapping have direct characterization tests. +- TypeScript, focused lint, the complete ChatHistory test surface, and the circular-dependency check pass; unrelated full-suite failures are identified explicitly. + +## Term overloading sweep + +| Term | Meaning in this change | Verdict | +| ---------------- | ------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| history | The raw event list supplied to `ChatHistory` | keep with reason — the existing public prop remains stable | +| projected groups | Grouped render records after filtering and display projection | pass — explicitly owned by `useChatHistoryProjectionModel` | +| turn page | The visible slice of projected groups selected by turn pagination | pass — kept distinct from browser/history-page navigation | +| active group | The clamped group used for minimap and overview selection | pass — derived in the navigation controller instead of mirrored by a repair effect | +| item actions | Edit, checkpoint restore, regenerate, and answer callbacks for rendered history items | pass — `useChatHistoryItemActions` avoids collision with the existing history-actions context | + +## Ten-layer audit + +| Layer | Area inspected | Verdict | Reason / evidence | Suggested change | +| ----: | ------------------------------------ | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | +| 1 | Compilation correctness | pass in scope | TypeScript and focused ESLint pass for every extracted module. The 21-file ChatHistory suite passes all 294 tests. The repository-wide run reaches 469 passing files but is blocked by a modified websocket schema test outside this scope and two dependency-level `ERR_REQUIRE_ESM` errors. | Resolve the websocket/schema and test-environment failures in their owning change. | +| 2 | Dead code and structural duplication | pass | `index.tsx` imports and invokes every extracted production hook; the render view consumes their returned models. No parallel copy of the former orchestration remains. | None. | +| 3 | Naming consistency | pass after fix | The initially ambiguous action-hook name was changed to `useChatHistoryItemActions`, separating item callbacks from the existing `useChatHistoryActions` context hook. | None. | +| 4 | Semantic overloading | pass | Raw history, projected groups, visible turn pages, and external history-page navigation are represented by separate names and owners. | None. | +| 5 | Default branches | pass | Existing defaults remain explicit in `ChatHistory.types.ts` and the entry point: standard grouping, collapsed groups, and inert external-navigation contracts. | None. | +| 6 | Cross-domain leakage | pass | Planning atom reads are isolated in `PlanningIndicatorBridge`; the view receives render data and callbacks without reaching into projection or viewport state. Agent-org data remains an explicit view prop. | None. | +| 7 | New-developer clarity | pass | The 206-line entry point reads top-to-bottom as state, projection, navigation, viewport, actions, and view composition. Each extracted filename describes its responsibility. | None. | +| 8 | Wire protocol / serialization | not applicable | No network payload, persistence shape, schema, or serialization boundary changed. | None. | +| 9 | Entry-point parity | not applicable | `ChatHistory` has one public production entry point; this refactor did not add alternate initialization paths. | None. | +| 10 | Resolver symmetry | not applicable | No multi-source configuration or identity resolver changed. | None. | + +## Systematic sweep + +- Traced the public `ChatHistory` entry point through each new hook and into `ChatHistoryView`; every new production abstraction is on that call chain. +- Searched the hook barrel and direct imports for the action-hook naming class; the item-action hook is now distinct from the existing context action hook. +- Ran the repository circular-dependency check across 5,286 files; it reported no circular dependency. +- Kept public prop exports at the package entry point while moving their definitions to `ChatHistory.types.ts`, avoiding duplicate prop contracts. +- Ran the repository-wide test suite: 469 files / 5,247 tests passed; `src/api/realtime/websocket/schemas.test.ts` has one out-of-scope failure in an independently modified file, and the runner reports two `html-encoding-sniffer` / `@exodus/bytes` module-format errors. + +## Summary + +- **fixes applied: 1** — resolved the action-hook naming collision. +- **abstractions introduced: 7** — projection, navigation, viewport, item actions, tail collapse, planning bridge, and render-only view. +- **kept with reason: 3** — public prop names/defaults, the existing hook barrel, and view-local `useGroupHeaderRenderer` composition. +- **not applicable layers: 3** — wire protocol, entry-point parity, and resolver symmetry. diff --git a/docs/architecture-audit-2026-07-17/CliVersionScan.md b/docs/architecture-audit-2026-07-17/CliVersionScan.md new file mode 100644 index 000000000..ab1b9ad35 --- /dev/null +++ b/docs/architecture-audit-2026-07-17/CliVersionScan.md @@ -0,0 +1,41 @@ +# CLI Version Scan Architecture Audit + +## Acceptance criteria + +- [x] A version scan starts only after the user selects a CLI in Session Creator. +- [x] Each actual scan probes the exact resolved executable with `--version` and queries its read-only official release source. +- [x] A fresh per-CLI observation is reused for twelve hours; there is no timer, scan-all path, or background polling loop. +- [x] Version scanning reads no credentials, invokes no updater, and writes nothing to Key Vault. +- [x] Session creation remains available when installed/latest version detection fails. +- [x] The inline warning appears only when both versions are comparable and the installed version is older. +- [x] Chat history preserves every CLI error, including repeated errors and errors preceding a recovered final reply. + +## 10-layer review + +| Layer | Scope checked | Verdict | Evidence | +| -------------------------- | -------------------------------------------------------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------- | +| 1. Compilation | integrations resolver, key-vault command, Tauri handler, RPC/Zod, React | pass | `cargo check -p org2 --bin org2` and `pnpm typecheck` | +| 2. Dead code / duplication | version parsing, status comparison, old repeated-error UI | pass | installed parsing is resolver-owned; latest parsing/status is command-owned; repeat-count field/rendering removed | +| 3. Naming | scan vs update; installed/latest/current/outdated/unknown | pass | public names describe observations and never imply mutation | +| 4. Semantic overloading | unavailable source, network failure, unparseable version, older version | pass | only a comparable older version maps to `outdated`; all other indeterminate cases map to `unknown` | +| 5. Default branches | missing binary, missing release source, timeout, endpoint error, cache hit | pass | bounded diagnostics are returned without blocking session creation | +| 6. Cross-domain leakage | Key Vault and credential environment | pass | scanner receives an agent type and resolved binary only; snapshot contains version metadata only | +| 7. New-developer confusion | cache ownership and scan trigger | pass | comments identify the twelve-hour backend/frontend caches and selection-only trigger | +| 8. Wire protocol | Rust snapshot ↔ Zod ↔ typed service/hook | pass | field names and enum values match exactly across the boundary | +| 9. Init parity | first selection, re-render, concurrent calls, forced future refresh | pass | frontend deduplicates promises; backend is authoritative for the twelve-hour cache | +| 10. Resolver symmetry | discovered binary vs launched binary | pass | version probe uses the centralized resolved executable path, not a new PATH lookup | + +## Default-branch analysis + +| State | Result | Session impact | +| -------------------------------------- | ------------------------------------- | -------------------------------------------------- | +| installed < latest | `outdated` | warning shown; launch remains enabled | +| installed = latest | `current` | no warning | +| installed > latest | `current` | no false warning for prerelease/newer local builds | +| installed/latest missing or unparsable | `unknown` | no warning; CLI errors remain visible in Chat | +| release endpoint fails/times out | `unknown` with bounded diagnostic | no warning; no retry loop | +| fresh cached snapshot | returned without process/network work | no added CPU/network churn | + +## Findings + +No unresolved architecture finding remains in this scope. Three CLIs currently have no stable read-only latest-version endpoint; they still report the installed version and remain `unknown` rather than invoking a self-updater or fabricating a latest version. Kiro and Antigravity use the version field from their official read-only release manifests. diff --git a/docs/architecture-audit-2026-07-18/SharedSessionPolling.md b/docs/architecture-audit-2026-07-18/SharedSessionPolling.md new file mode 100644 index 000000000..32e108024 --- /dev/null +++ b/docs/architecture-audit-2026-07-18/SharedSessionPolling.md @@ -0,0 +1,121 @@ +# Shared-session polling architecture audit + +Scope: managed-cloud shared sessions (`Org2Cloud` + the surviving `TeamCollaboration` project bridge), including the root sync/realtime entry points, comments, remote-session listings, project/work-item sync, comment-task execution, and the API-call hotspot UI shown in the supplied capture. + +## Outcome + +The dominant problem is not a collection of independent timers. It is one coarse Realtime invalidation that behaves like event-driven polling: every `org_change_signals` row update fans out into roster, entitlement, repo-scope, project/work-item, comment-task, remote-session, and open-comment-thread reads. Several of those reads then multiply across every org or across duplicate mounted consumers. + +Implementation update: the client-side multipliers identified here are now fixed. Realtime inbound requests are per-org and delta-preserving, reconnect is the only full-listing path, waiting no longer starts a second pass, ordinary signals no longer read the org roster, and sharing-policy recovery is a per-org entitlement read capped at once per minute. Remote-session/comment requests share keyed deduplication, optimistic comment mutations notify peers without invalidating themselves, Realtime-only project pulls skip the outbox drain, roster changes reload only members, and address-comment completion is event-driven with a 60-second deadman check. The remaining coarse behavior requires a server wire-protocol change: `org_change_signals` still lacks a plane, entity/session hint, origin, and monotonic per-plane revision. + +The screenshot is consistent with this call graph: + +- 9 `cloud_list_org_collab_state`, 9 `project_collab_outbox_drain`, and 9 `cloud_list_comment_tasks` calls form one repeated sync-cycle cluster. +- 18 `cloud_list_session_comments` calls are consistent with two simultaneously mounted `useSessionComments` consumers for the same session. The second forced subscriber queues a second fetch behind the first. +- 16 `list_my_orgs` calls versus 9 serialized sync cycles are consistent with Realtime callbacks starting roster reads without the sync engine's coalescing. +- The panel says **9 likely polling** but renders only six cards because `PanelContent.tsx` uses `hotspots.slice(0, 6)`. The exact identities of the hidden three are not recoverable from the PNG. Source-derived candidates are `get_entitlement_state`, `cloud_list_org_sessions`, and `project_collab_apply_remote` when a non-empty full listing is repeatedly applied. + +```mermaid +flowchart LR + S["org_change_signals update"] --> R["refetchOrgs"] + R --> O["list_my_orgs"] + R --> E["get_entitlement_state × every org"] + S --> I["invalidateOrgInbound(orgId)"] + I --> F["global inbound pass"] + F --> Q["cloud_get_org_repo_scopes"] + F --> P["cloud_list_org_collab_state × every org"] + P --> D["project_collab_outbox_drain × every org"] + F --> T["cloud_list_comment_tasks × every org"] + S --> L["remote-session version bump"] + L --> LS["cloud_list_org_sessions × mounted consumer"] + S --> C["org-wide comments version bump"] + C --> LC["cloud_list_session_comments × open session × duplicate subscriber"] +``` + +## Acceptance criteria for a polling fix + +- One ordinary server mutation causes at most one coalesced delta pull for the affected org and affected plane. +- A normal Realtime event does not clear full-listing latches; only initial connect/reconnect or an explicit revocation-reconciliation event requests a full listing. +- A change in org A does not pull project or comment-task state for org B. +- Project/work-item/comment/session changes do not refetch `list_my_orgs`, entitlement, members, or repo scopes. +- One comments invalidation causes at most one listing for the affected session regardless of mounted surfaces; an already-patched local mutation causes no local listing. +- One remote-session invalidation causes at most one listing per org regardless of sidebar/panel consumers. +- Attaching a waiter to an active sync pass does not mark that pass dirty or schedule another pass. +- An empty project outbox is not drained on every inbound pull. +- Long-running address-comment runs observe authoritative session-finality events; status polling is fallback-only. +- The hotspot UI can reveal all groups that contribute to its “likely polling” count. + +## Finding sweep + +| Priority | Line / element | Verdict | Reason | Suggested change | +| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| P0 | `useOrg2CloudRealtime.ts:215-233` — `org_change_signals` `onChange` | fix | One untyped org-wide event triggers roster refetch, entitlement hydration, full inbound sync, remote-session refresh, every open comment thread, and task-runner work. A session push, project update, work-item update, task heartbeat, or comment edit all pay for unrelated planes. | Put plane/revision/session metadata on the signal (or split signals by plane). Coalesce by `(orgId, plane)` and dispatch only the affected consumers. Include an origin/revision so a client can cheaply ignore or acknowledge its own already-applied write. | +| P0 | `org2CloudSyncEngine.ts:521-536, 909-961` — `forceInboundNextPass` | fix | The caller supplies `orgId`, but the engine stores one global boolean. The next inbound pass loops over **all orgs**, so one org's event pulls every org's project/work-item and task planes. | Replace the boolean with pending per-org plane masks, e.g. `Map>`; consume only requested orgs/planes. Reserve an explicit all-org mode for reconnect recovery. | +| P0 | `org2CloudSyncEngine.ts:523-527, 1070-1078, 1145-1153` — full-listing latch reset | fix | Every ordinary signal deletes the affected org's full-listing latches. The next project and task reads therefore omit their persisted cursors and fetch complete listings, defeating the delta design documented in those methods. | Keep cursors/latches for ordinary change events. Request `full` only on initial subscribe/reconnect, membership/visibility revocation, or a signal that explicitly says absence reconciliation is required. | +| P0 | `useOrg2CloudRealtime.ts:220`; `org2CloudOrgsAtom.ts:308-366` — roster/entitlement refresh | fix | Every broad change signal invokes `list_my_orgs`; every successful refetch then launches `get_entitlement_state` once per org. Calls are not single-flight, so bursts still spend network work even when older responses are discarded by request epochs. | Refetch roster only on membership/org lifecycle events. Give roster and entitlement separate cached stores, single-flight per key, TTL/backoff, and explicit invalidations. Do not hydrate entitlements as a side effect of every roster read. | +| P0 | `SessionCommentsHeaderExtras.tsx:50-58`; `SessionCommentsContext.tsx:202-216`; `org2CloudSessionCommentsAtom.ts:460-483, 538-542` — duplicate comments subscribers | fix | The active shared session mounts two `useSessionComments` instances for the same key. On a forced signal the first claims the fetch; the second sees `loading` and records `queue_force`, guaranteeing a second sequential listing even though both observed the same signal generation. This matches the screenshot's 18 comment calls versus 9 sync-cycle calls. | Move invalidation consumption into a module-level keyed request coordinator, deduped by signal generation. Alternatively make the header consume a shared session-level store/provider bridge instead of owning a second fetching hook. Queue a second force only when its server revision is newer than the in-flight request's revision. | +| P1 | `org2CloudCommentsBus.ts:59-70`; `org2CloudSessionCommentsAtom.ts:651-729`; `SessionCommentsContext.tsx:256-270` — local broadcasts | fix | Add/edit/delete/resolve already patch the local atom from the mutation result, but `broadcastCommentsChanged` also bumps the local signal and forces a listing. Task creation explicitly calls `refresh()` and then bumps the same signal. With two consumers this commonly becomes two follow-up listings, followed later by the durable org signal. | Split “notify peers” from “invalidate this client.” Optimistically patched callers should broadcast to peers without bumping local fetch state. Callers lacking a returned row should perform one keyed local refresh, not both `refresh()` and local broadcast invalidation. | +| P1 | `org2CloudRemoteSessionsAtom.ts:122-183`; `cloudSessionsSection.tsx:159`; `CloudSessionsSection.tsx:37` — per-hook request locks | fix | Sidebar and cloud-management session sections can mount for the same org. Each hook instance owns its own in-flight set and last-version map, so both may issue `cloud_list_org_sessions` for one invalidation. The broad signal also bumps this list for project/task/comment-only changes. | Store the in-flight promise and processed revision at module/store scope by org. Invalidate this list only for session/share/access changes. Reuse the returned server session row after local push/share mutations where possible. | +| P1 | `useOrg2CloudRealtime.ts:220-224`; `org2CloudSyncEngine.ts:474-509, 521-536` — invalidate then wait | fix | `invalidateOrgInbound()` starts `runSyncPass()` immediately. The following `runSyncPassAndWaitForDrain()` calls `runSyncPass()` again; because a pass is active, it sets `passDirty`, guaranteeing a follow-up pass merely to attach a waiter. The same pattern appears in `resumeOrg(); runSyncPassAndWaitForDrain()` callers. | Expose one request API that records intent and optionally returns a drain promise, e.g. `requestSync(intent, { wait: true })`. Attaching a waiter must not itself set dirty. Dirty should mean new unconsumed work only. | +| P1 | `ProjectSyncChannel.ts:115-119, 142-152` — unconditional outbox drain | fix | Every project pull calls `pushOutbox`, which invokes `project_collab_outbox_drain` even when no local mutation exists. The screenshot's drain count exactly tracks project-state pulls. | Separate inbound apply from outbound drain. Maintain a per-org dirty/pending signal from local mutations and Rust retry deadlines; drain only when dirty/retry is due. The low-frequency safety pass may retain a bounded pending probe. | +| P1 | `ProjectSyncChannel.ts:121-139`; `org2CloudSyncEngine.ts:1070-1093` — repeated full remote apply | fix | Resetting the full-listing latch means the same non-empty project/work-item set is repeatedly sent through `project_collab_apply_remote`; Rust may no-op it, but IPC, decoding, and field comparisons are still paid. `notifyDataChanged` can then schedule another projects pass when anything applies. | Preserve delta cursors on normal events and include a server revision gate before IPC. Keep the existing `entities.length === 0` skip; add “revision already applied” identity/no-op protection before calling the bridge. | +| P2 | `CloudOrgPanelView/index.tsx:254-330` — roster-version effect | fix | A teammate membership change makes the panel refetch entitlement, full member roster, and repo scopes together. Only the member roster is necessarily stale; the other calls can appear as hidden hotspots while the panel is open. | Split the panel loaders by data owner and invalidation key. Membership version refreshes members; plan/policy refreshes entitlement; scope-policy version refreshes scopes. Back them with shared per-org caches. | +| P2 | `addressCommentsRun.ts:388-400` — 4-second status poll | fix | While an address-comments run is active, `SessionService.getStatus` runs 15 times/minute for up to 15 minutes (up to 225 status calls), despite the app already having session lifecycle/event infrastructure. This is not in the six visible cloud RPC cards but is shared-session-specific polling. | Await the authoritative turn/session terminal event or lifecycle store with a generation guard. Retain a much slower deadman poll only for dropped-event recovery and preserve the 15-minute deadline. | +| P2 | `router/index.tsx:26-35`; `org2CloudSyncEngine.ts:412-430`; `useOrg2CloudRealtime.ts:167-180, 235-243` — startup/reconnect burst | fix | Root startup independently starts an immediate sync pass, a roster subscribe compensation read, and one full inbound invalidation per org. Near-simultaneous subscriptions are serialized but can leave dirty follow-ups and duplicate complete listings. | Add a bootstrap barrier: load roster, establish subscriptions, then issue one all-org full reconciliation generation. Reconnects should coalesce true-edge status events across channels into one bounded recovery pass. | +| P3 | `PanelContent.tsx:60-64, 77-79` — hotspot visibility | fix | The heading counts every likely-polling group but the UI renders only the first six, which is why the capture says 9 while showing 6. This hides lower-rate multipliers from the exact workflow used to diagnose them. | Add “show all”/expand behavior or render all likely-polling rows before non-polling rows. Preserve the six-card compact default only when the count is six or fewer. | +| Keep | `org2CloudSyncEngine.ts:151-198, 544-573` — recurring safety chain | keep with reason | One 60-second visible timer, stretched to five minutes while hidden, is a reasonable outbound safety net. Inbound work is already gated to five minutes and scopes/events to ten minutes. The timer is not the source of the 4.5-9/min rates; the coarse invalidation bypass is. | Keep the single chain, but make every pass consume precise dirty/revision state. Consider moving the ordinary visible safety cadence to five minutes once event-driven outbound coverage is proven. | +| Keep | `commentTaskRunner.ts:388-416`; `org2CloudCommentTasksClient.ts:40-44` — lease heartbeat | keep with reason | A 60-second heartbeat renews a 15-minute fenced lease and carries bounded progress only while a real task is running. It is protocol maintenance, not idle polling. | Keep. Ensure heartbeats do not invalidate unrelated planes; their task-plane signal should update only that task/org. | +| Keep | `org2CloudRealtimeClient.ts:258-315` — presence retry | keep with reason | The one-second retry is failure-driven, coalesced to the latest payload, and constrained by a connection-wide five-calls/30-second scheduler. It is not an always-on loop. | Keep; expose retry counts in push/timer diagnostics if it becomes noisy. | +| Keep | `org2CloudOrgsAtom.ts:197-285` — initial roster retry | keep with reason | The 2/5/10/20-second retry sequence is bounded and exists only after initial-load failure. The excessive roster traffic comes from unbounded broad-signal refetches, not this recovery sequence. | Keep the bounded recovery, but share its single-flight/backoff state with imperative roster refreshes. | + +## Pre-fix trigger / call matrix + +| Trigger | Current remote/IPC effects | Excess mechanism | +| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | +| Any `org_change_signals` change | `list_my_orgs`; `get_entitlement_state × orgs`; scopes for invalidated org; project/task pulls for all orgs; outbox drains for all orgs; remote-session listing for mounted active org; comment listings for every mounted open session in org | Coarse signal, global boolean, full-latch reset, per-hook locks, no roster single-flight | +| Local shared-session event write | 3-second debounced sync; session metadata/events push; server signal echoes back into the full fan-out above | Legitimate outbound write causes broad self-invalidation | +| Local project/work-item mutation | 1.5-second project pass; project pull + outbox drain/push; server signal; possible remote-apply `orgii-data-changed` follow-up | Push and pull are inseparable; empty drains and one accepted echo pass | +| Local comment mutation | Mutation RPC; local atom patch; local forced listing; peer broadcast; durable org signal; another forced listing | Local notification is conflated with peer invalidation; duplicate subscribers queue a second force | +| Realtime reconnect/startup | Roster read plus one full invalidation per org plus independent bootstrap sync | No connection-generation bootstrap barrier | +| Address-comments execution | `session_get_status` every 4 seconds; task lease heartbeat every 60 seconds when applicable | Status completion is polled rather than observed; heartbeat is intentional | + +## Term-overloading sweep + +| Term | Current meanings | Verdict | +| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| `org_change_signals` | Session rows, comments, tasks, projects, work items, sharing policy, and possibly scope/entitlement changes | fix — one name/payload cannot express which consumer is stale | +| `invalidateOrgInbound(orgId)` | Clear backoff, clear full-listing state, clear scope TTL, force all-org inbound work, and start execution | fix — invalidation intent and scheduling are overloaded | +| `runSyncPass` | Session outbound push, repo-scope hydration, project pull, project outbox push, comment-task pull, reconciliation, and retry | keep only behind plane-specific intent; the current entry point is too broad for every trigger | +| `force` comments refresh | A genuinely newer server revision, a duplicate subscriber observing the same signal, an explicit user refresh, or a mutation caller requesting reconciliation | fix — carry a revision/reason so equal work dedupes and newer work queues | +| “polling” in DevTools | Fixed timers and repeated event-driven fetch loops are both classified from repeated automatic calls | keep with reason — the monitor correctly exposes the user-visible cost even when the trigger is Realtime rather than a timer | + +## Ten-layer audit + +| Layer | Area inspected | Verdict | Reason / evidence | Suggested change | +| ----: | ---------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | Compilation correctness | pass | Full TypeScript check passes. Six focused suites pass: 139 tests, including scoped/delta Realtime pulls, pull-only project-channel coverage, and event-driven status completion. | Keep the focused call-budget assertions as regression gates. | +| 2 | Dead code / structural duplication | fix | Two comments fetch owners and two possible remote-session fetch owners exist for the same keys; push/pull scheduling also has parallel “invalidate then wait” entry points. | Centralize keyed request ownership and sync intent scheduling. | +| 3 | Naming consistency | fix | `invalidateOrgInbound(orgId)` sounds org-scoped but sets a global inbound flag and clears unrelated caches/latches. | Name intent explicitly: org set, plane mask, delta/full mode, wait policy. | +| 4 | Semantic overloading | fix | One change signal and one sync pass carry several unrelated domains. `force` cannot distinguish a newer revision from a duplicate observer. | Use typed plane/revision/origin signals and reasoned fetch requests. | +| 5 | Default-branch analysis | fix | With no plane discriminator, the implicit default is “refresh everything.” With no revision discriminator, the default forced in-flight behavior is “queue another fetch.” These defaults are unsafe under new signal producers or new consumers. | Make exhaustive plane handling mandatory and ignore equal/older revisions. | +| 6 | Cross-domain leakage | fix | The Realtime React hook directly orchestrates roster, entitlement, sync engine, sidebar sessions, comments, and task execution. Transport delivery knows product-domain refresh policy. | Route transport frames into a domain-neutral invalidation coordinator; each domain consumes its own typed revision. | +| 7 | New-developer clarity | fix | Comments claim the engine has the only recurring pull and header atom sharing prevents duplicate fetches, but event-driven refetches and forced queueing violate the practical guarantee. | Document call budgets and ownership invariants next to the coordinator; replace comments that describe timer count with end-to-end request-count guarantees. | +| 8 | Wire protocol / serialization | fix | `org_change_signals` carries insufficient invalidation information: the client has only org identity, not plane/session/entity revision or origin. That forces full RPC reads to discover what changed. | Extend the signal row/payload or use per-plane signal rows with monotonic revisions and optional entity/session hints. Inspect the actual Realtime payload in an integration test. | +| 9 | Entry-point parity | fix | Bootstrap timer, visibility resume, local event activity, local project mutations, Realtime, reconnect compensation, and user policy changes all enter the same pass with different intent, and some callers start then separately wait. | One `requestSync(intent)` entry point should merge intent monotonically and provide optional drain completion without scheduling duplicate work. | +| 10 | Resolver symmetry | fix | The method accepts one org, clears only that org's latches, then resolves work through an all-org boolean. Comments resolve a session key but also listen to an org-wide wildcard. Scope, project, task, and session refresh dimensions are asymmetric. | Carry org and plane identity through every stage; resolve only targeted keys, with an explicit all-org/all-plane recovery mode. | + +## Implementation status + +1. Done client-side: coalescing per-org sync intent, delta-preserving ordinary invalidations, full reconnect recovery, and one drainable request API. +2. Partly done: roster was removed from broad signals and inbound work is org-scoped. Server-side plane/revision/origin metadata remains the highest-value follow-up. +3. Done: comments dedupe by signal generation, remote-session locks/version watermarks are store-shared, and peer-only comment broadcasts avoid local read-after-write fetches. +4. Done: Realtime-only project pulls do not probe the outbox; address-comment status uses the session store with a sparse recovery check. +5. Partly done: focused request-count regressions and all likely-polling DevTools cards are present. A dual-instance end-to-end call-budget assertion remains recommended. + +## Verification performed + +- Systematic source sweep for `setInterval`, recursive `setTimeout`, polling/status loops, Realtime invalidations, forced refreshes, sync-pass entry points, and all cloud RPC names across `Org2Cloud` and `TeamCollaboration`. +- Forward call-chain trace from the router root and `org_change_signals` callback through roster, sync, project bridge, remote-session, comments, and task-runner consumers. +- Mount trace confirming the duplicate comments consumers and the possible duplicate remote-session consumers. +- Full typecheck: `pnpm typecheck` — passed. +- Focused tests: `pnpm vitest run src/features/Org2Cloud/org2CloudSyncEngine.test.ts src/features/Org2Cloud/org2CloudSessionCommentsAtom.test.ts src/features/Org2Cloud/addressCommentsRun.test.ts src/features/Org2Cloud/org2CloudOrgsAtom.test.ts src/features/TeamCollaboration/engine/ProjectSyncChannel.test.ts src/modules/shared/DevTools/APICallPanel/components/PanelContent.test.ts` — 139/139 passed. diff --git a/docs/architecture/agentsview-lessons-orgtrack-session-analytics--0624.md b/docs/architecture/agentsview-lessons-orgtrack-session-analytics--0624.md index 501c75329..15d70f422 100644 --- a/docs/architecture/agentsview-lessons-orgtrack-session-analytics--0624.md +++ b/docs/architecture/agentsview-lessons-orgtrack-session-analytics--0624.md @@ -9,7 +9,7 @@ status: active Integrate the useful parts of `agentsview` into ORGII without creating a second session analytics system. ORGII should keep `orgtrack` and the existing `unified_stats` Tauri API as the shared pipeline for session discovery, -activity, token accounting, cost reporting, Dev Record, ops control, and chat +activity, token accounting, cost reporting, Dev Record, Kanban, and chat start-page analytics. ## What agentsview does well @@ -92,7 +92,7 @@ Dev Record’s shared `HeatmapGrid`. The card displays: This keeps the heatmap visible at the point where users decide what work to do next. -### Dev Record / ops-control improvements +### Dev Record / work-management improvements Dev Record session rows now use computed cache-aware cost instead of hardcoded zero-cost placeholders. Expanded per-round rows show cache write/read token diff --git a/docs/architecture/managed-cloud-collaboration.md b/docs/architecture/managed-cloud-collaboration.md new file mode 100644 index 000000000..dbd48bbfb --- /dev/null +++ b/docs/architecture/managed-cloud-collaboration.md @@ -0,0 +1,201 @@ +# Managed Cloud Collaboration + +This document is the canonical design for ORGII managed-cloud collaboration. +It replaces the dated implementation audits and E2E run reports that were +useful while the feature was being built but are not part of the maintained +product contract. + +## Product model + +ORGII has three collaboration scopes with deliberately different semantics: + +1. **Personal** is local/private. It never exposes a cloud organization's + roster and cannot be used as a destination for direct member sharing. +2. **Organization** is the durable team boundary. Membership, roles, sharing + policy, Projects, Work Items, comments, and direct session grants are + authorized by the managed backend. +3. **Link capability** is an explicit guest path. Creating a link is separate + from sharing with an organization member and the link can expire or be + revoked independently. + +Selecting an organization member creates a direct grant; it does not generate +a link. The recipient sees that session in **Shared directly with me** without +copying a URL. Link generation remains an explicit action and always exposes a +Copy control. + +An imported shared session or local Codex/Claude/Cursor history is immutable +at its source. The user may inspect and comment where cloud authorization +exists. On the first attempt to continue the conversation, ORGII asks for a +local repository/workspace with the same Git remote plus the local account and +model, then creates a writable ORGII-owned fork and sends the message there. +Cancelling the picker preserves the unsent message. + +## Ownership and authorization + +- The backend is authoritative for cloud organization membership, roles, + policies, grants, invite state, ownership transfer, and deletion. +- Local aliases connect a cloud organization to local project storage but do + not redefine cloud identity or authorization. +- A direct grant, organization visibility, and a link capability are separate + authorization concepts. Code must not infer one from another. +- Revocation and deletion invalidate local caches immediately through + Realtime. Polling is recovery-only. +- The last-owner and role-transition invariants are server transactions, not + UI conventions. + +## Data planes + +### Durable entities + +Projects and Work Items use local-first persistence plus a durable SQLite +outbox. Each mutation has one typed entity operation, a stable entity ID, +organization scope, and an expected remote version. The sync engine: + +1. commits the local mutation and outbox record atomically; +2. pushes eligible operations in order; +3. applies optimistic concurrency control (OCC) at the backend; +4. pulls/invalidate on Realtime signals; +5. resolves or surfaces conflicts deterministically; and +6. retries at the recorded eligibility time, including while the app is + hidden. + +Remote tombstones are permanent convergence operations. User-initiated local +deletion may remain recoverable, so the sync worker uses a distinct purge path. +Deleting a Project also deletes its child Work Items; children must never be +silently converted into standalone items by an FK default. + +### Comments and agent tasks + +Session comments are durable cloud rows. Replies retain their thread root, +edits and deletes converge live, and status is a typed tri-state value. An +`@agent` mention is stored as structured task intent and rendered as a pill, +not inferred later from plain text. The Address Comments action operates on an +explicit selection and links agent output back to the originating comment. +Top-level comments have exactly one scope: no event anchor means a session +note applying to the session as a whole; an event anchor means a round comment. +Address Comments groups both scopes, selects both by default, permits +scope-level selection, and carries the scope into the agent briefing. + +### Presence + +Presence is ephemeral awareness, never the source of truth for membership, +locks, or durable edits. Only the active organization publishes tracking +state. Inactive channels may listen, and connection-wide updates are +coalesced to stay within transport limits. Leaving an organization untracks +before disposing its channel. + +### Execution locks + +A Work Item execution lock identifies the active session and role. Start, +retry, cancel, and lock-holder UX all use the same Work Item orchestrator in +detail views and ChatPanel. Lock release is serialized as explicit JSON +`null`; an omitted field means "unchanged", not "clear". + +## Create with AI + +Create with AI uses `builtin:work-item-manager` by default and follows one +durable-draft invariant: + +1. Before launch, the UI allocates a cloud-aware Work Item ID and writes one + draft in the selected Project or organization-scoped standalone store. +2. The launched session is durably linked to that draft. +3. The Work Item Manager receives a volatile system section containing the + exact `short_id` and Project scope. It updates the linked draft instead of + creating a duplicate unless the user explicitly asks for multiple items. +4. `project_slug` is omitted for standalone Work Items; a fake Personal + Workspace Project is never invented. +5. The session link survives every update and is visible from both the Work + Item and session surfaces. + +The Work Item Manager may research with read-only tools and mutate Projects or +Work Items through their typed management tools. It cannot edit repository +files or run shell commands. + +## Client boundaries + +- `org2CloudClient` owns authenticated HTTP transport and token refresh. +- Entity-specific clients own wire shapes for organizations, sessions, + shares, comments/tasks, Projects, and Work Items. +- `org2CloudRealtimeClient` owns subscription lifecycle and reconnect + behavior. +- `org2CloudSyncEngine` owns durable project-plane convergence and retry. +- Jotai atoms expose UI state; they do not become alternate persistence or + authorization layers. +- Rust owns local project/work-item transactions, session persistence, + execution locks, agent tools, and desktop commands. + +There is one endpoint snapshot per authenticated operation. A token refresh +must not silently move an in-flight request to a different endpoint. Unknown +credential/provider rows are preserved when an older build updates a known +account so account refresh cannot destroy forward-version data. + +## Desktop instance isolation + +Instance profiles are created at build time. Instance `N` has an independent: + +- product name and bundle identifier; +- deep-link schemes; +- ORGII home and WebKit storage; +- IDE server port (`13846 + N`); and +- local managed-cloud proxy port (`17887 + N`). + +The supported commands are: + +```sh +pnpm run tauri:build:fast +open src-tauri/target/dev-build/bundle/macos/ORG2.app + +pnpm run tauri:build:fast -- --instance 2 +pnpm run tauri:open:instance -- --instance 2 +``` + +Copying and patching an already-built app bundle is not supported because it +can leave the frontend, schemes, ports, and data home with different identity. + +## Backend migration contract + +Backend RPC/schema changes live in `orgii-cloud-infra` and must be deployed +before a desktop release that calls them. Migrations are dated, idempotent, and +must be run against local Supabase twice before production rollout. In +particular, production must include the Work Item lock-release delta that +writes `executionLock: null`; the desktop cannot compensate for an old RPC +that removes the key. + +Desktop code must not auto-run production SQL or mutate production +organizations during tests. Production migration is an explicit operator +step with the infra repository's migration history as the source of truth. + +## Verification contract + +Changes to this feature are complete only when the affected layers pass their +own gates. A skipped scenario is never reported as a pass. + +- TypeScript: typecheck plus focused unit tests for changed clients, atoms, + reducers, filters, clipboard, and UI state machines. +- Rust: focused crate tests for changed persistence, tools, session launch, + locks, and sync code. +- Local cloud: Auth/PostgREST/Realtime/RPC assertions against disposable users. +- Rendered single-instance UI: signed-out/in, organization scope, sharing, + import/fork, comments/tasks, presence policy, Projects, Work Items, and real + provider execution. +- Rendered dual-instance UI: isolation, invite/join, direct share, link share, + revoke, comments, Project/Work Item convergence, lock ownership, offline OCC, + roles, leave/remove/reactivate, ownership transfer, and typed deletion. +- Create with AI: a real provider must update the single linked draft through + the rendered composer, preserve the session link, and create no duplicate. +- Packaging: build and launch main plus Instance 2 concurrently and verify + listener ownership and visible identity. + +OAuth-live runs use an explicitly selected real account. Tests must never fall +back to an unrelated account merely because it is available. + +## Explicit non-goals + +- Presence is not a durable collaborative document protocol. +- Typed business entities are not converted wholesale to CRDTs. A future rich + document body may use a per-artifact CRDT behind a narrow interface while + metadata, ACLs, Work Item transitions, tombstones, and OCC stay typed. +- Guest links do not grant organization membership. +- Import does not make remote history writable. +- Browser OAuth callback allow-list verification and production migration are + external release gates; deterministic local JWT tests do not prove them. diff --git a/docs/architecture/warp-imported-history.md b/docs/architecture/warp-imported-history.md new file mode 100644 index 000000000..9b92919dd --- /dev/null +++ b/docs/architecture/warp-imported-history.md @@ -0,0 +1,77 @@ +# Warp imported history + +ORGII imports Warp's locally stored Agent conversations as read-only external history. It does not launch Warp, mutate Warp data, or attempt to download cloud-only conversations. + +## Source contract + +| Field | Value | +| --------------------- | --------------------------------- | +| Source ID | `warp` | +| ORGII session prefix | `warpapp-` | +| Store kind | SQLite | +| Database | `warp.sqlite` | +| Conversation metadata | `agent_conversations` | +| Transcript payload | `agent_tasks.task` protobuf blobs | +| Protobuf message | `warp.multi_agent.v1.Task` | + +The importer uses Warp's published descriptor at revision [`2d0e8dd`](https://github.com/warpdotdev/warp-proto-apis/tree/2d0e8ddf5a946a663f7e0952144ccbced0068a81) through `warp_multi_agent_api`. Dynamic protobuf decoding avoids a parallel hand-maintained schema and preserves compatibility with fields the importer does not yet interpret. + +## Database discovery + +Candidates are tried in a stable order and deduplicated. The first existing database is opened with SQLite read-only flags. + +| Platform/channel | Candidate | +| ---------------- | -------------------------------------------------------------------------------------------------------------- | +| macOS Stable | `~/Library/Group Containers/2BBY89MBSN.dev.warp/Library/Application Support/dev.warp.Warp-Stable/warp.sqlite` | +| macOS Preview | `~/Library/Group Containers/2BBY89MBSN.dev.warp/Library/Application Support/dev.warp.Warp-Preview/warp.sqlite` | +| macOS legacy | `~/Library/Application Support/dev.warp.Warp-{Stable,Preview}/warp.sqlite` | +| Linux | `${XDG_STATE_HOME:-~/.local/state}/warp-terminal/warp.sqlite` | +| Windows | `%LOCALAPPDATA%/warp/Warp/data/warp.sqlite` | + +The same candidate function is shared by import and Data Sources detection so discovery cannot drift. + +Executable detection recognizes the current `oz`/`oz-preview` names, the deprecated `warp-cli` name, and Linux's `warp-terminal` desktop launcher. This only improves the Data Sources inventory; it does not add a live CLI runner. + +## Mapping into ORGII + +| Warp event | ORGII replay chunk | +| -------------------------------------- | -------------------------------------- | +| `userQuery` | user message | +| `agentReasoning` | thinking | +| `agentOutput` | assistant message | +| `modelUsed` | session model metadata | +| `toolCall` + matching `toolCallResult` | one tool-call chunk with paired output | + +Known tools map to ORGII's canonical shell, file-read, file-edit, code-search, and glob operations. Unknown tools retain a snake-case name and their complete JSON payload rather than being discarded. `applyFileDiffs` also contributes touched-file and line-impact statistics. + +Messages from every task in a conversation are merged by protobuf timestamp, then task/message position. Missing timestamps fall back to the conversation's `last_modified_at`. + +## Metadata fallbacks + +| Field | Resolution order | +| -------------------- | ------------------------------------------------------------------------------------------------------------------- | +| Title | summary title → root-task description → summary initial query → first user query → agent name → `Warp conversation` | +| Model | latest `modelUsed` event → latest non-empty usage model | +| Repository path | summary initial working directory | +| Created/updated time | earliest/latest message timestamp → conversation `last_modified_at` | +| Parent | `parent_conversation_id`, wrapped with `warpapp-` | + +Remote child conversations and unlisted automatic code-diff conversations are retained in the cache input set but are not listed as primary sessions. Conversations with no decodable replay chunks are also hidden. + +## Cache and compatibility + +The shared imported-history cache fingerprints conversation JSON, optional summary JSON, modification time, task count/bytes, and SQLite WAL/SHM sidecars. Parser version changes can invalidate cached rows. Databases that predate the optional `summary` column are supported; missing tables and malformed protobuf records degrade to an empty result instead of crashing the source scan. + +## Privacy and limitations + +- Reads are local and read-only. +- ORGII only sees conversations present in the local `warp.sqlite` database. +- Warp can store/sync some data remotely depending on user settings; cloud-only history is outside this importer. +- This implements issue #366 (history import), not the separate Warp CLI/TUI integration tracked by #331. + +## References + +- [Warp: interacting with agents](https://docs.warp.dev/agent-platform/local-agents/interacting-with-agents) +- [Warp: session restoration and database locations](https://docs.warp.dev/terminal/sessions/session-restoration) +- [Warp repository migration code](https://github.com/warpdotdev/Warp/blob/main/warp-repository/src/migration.rs) +- [Warp protobuf APIs](https://github.com/warpdotdev/warp-proto-apis) diff --git a/docs/assets/feature-wall/ai-blame.gif b/docs/assets/feature-wall/ai-blame.gif new file mode 100644 index 000000000..81a5cadce Binary files /dev/null and b/docs/assets/feature-wall/ai-blame.gif differ diff --git a/docs/assets/feature-wall/design-mode.gif b/docs/assets/feature-wall/design-mode.gif new file mode 100644 index 000000000..2e7f51a1e Binary files /dev/null and b/docs/assets/feature-wall/design-mode.gif differ diff --git a/docs/assets/feature-wall/development-workspace.gif b/docs/assets/feature-wall/development-workspace.gif new file mode 100644 index 000000000..97133f17a Binary files /dev/null and b/docs/assets/feature-wall/development-workspace.gif differ diff --git a/docs/assets/feature-wall/replay.gif b/docs/assets/feature-wall/replay.gif new file mode 100644 index 000000000..e777da7c6 Binary files /dev/null and b/docs/assets/feature-wall/replay.gif differ diff --git a/docs/assets/feature-wall/session-sources.png b/docs/assets/feature-wall/session-sources.png new file mode 100644 index 000000000..98e4502ad Binary files /dev/null and b/docs/assets/feature-wall/session-sources.png differ diff --git a/docs/assets/feature-wall/team-trajectory-review.png b/docs/assets/feature-wall/team-trajectory-review.png new file mode 100644 index 000000000..de4466ced Binary files /dev/null and b/docs/assets/feature-wall/team-trajectory-review.png differ diff --git a/docs/assets/feature-wall/work-diary.png b/docs/assets/feature-wall/work-diary.png new file mode 100644 index 000000000..47ae3b241 Binary files /dev/null and b/docs/assets/feature-wall/work-diary.png differ diff --git a/docs/assets/session-provenance/runtime-hooks.png b/docs/assets/session-provenance/runtime-hooks.png new file mode 100644 index 000000000..e37b2b563 Binary files /dev/null and b/docs/assets/session-provenance/runtime-hooks.png differ diff --git a/docs/assets/session-provenance/session-blame.png b/docs/assets/session-provenance/session-blame.png new file mode 100644 index 000000000..d61220840 Binary files /dev/null and b/docs/assets/session-provenance/session-blame.png differ diff --git a/docs/audit-2026-06-10/naming-collisions.md b/docs/audit-2026-06-10/naming-collisions.md index 6d7853108..14e7fecfd 100644 --- a/docs/audit-2026-06-10/naming-collisions.md +++ b/docs/audit-2026-06-10/naming-collisions.md @@ -114,7 +114,7 @@ BE: ### `mode`(3 套状态机) 1. `agentExecMode`(build / ask / plan / debug / review / wingman) -2. `stationMode`(workstation surface mode:agent-station / my-station / ops-control 等) +2. `stationMode`(workstation surface mode:agent-station / my-station / work-management 等) 3. `chatPanelContentModeAtom`(哪个 content view 显示) ### `pill`(3 个 UI primitive) diff --git a/docs/contributing/wiki/Architecture-Overview.md b/docs/contributing/wiki/Architecture-Overview.md index 038072fdc..cbaef832c 100644 --- a/docs/contributing/wiki/Architecture-Overview.md +++ b/docs/contributing/wiki/Architecture-Overview.md @@ -93,7 +93,7 @@ The core of the backend. Each CLI agent type has its own: - **Platform adapter** — spawns the agent process, manages its stdin/stdout/stderr. - **Output parser** — converts raw agent output into structured events (tool calls, messages, file edits). -Supported CLI agents: Cursor, Claude Code, Codex, Copilot, Gemini CLI, Kiro, OpenCode. +Supported CLI agents: Cursor, Claude Code, Codex, Copilot, Antigravity, Kiro, OpenCode. ### `crates/agent-core/` diff --git a/docs/contributing/wiki/Sessions.md b/docs/contributing/wiki/Sessions.md index 13eea5c4d..b25d93f44 100644 --- a/docs/contributing/wiki/Sessions.md +++ b/docs/contributing/wiki/Sessions.md @@ -6,11 +6,11 @@ A **session** is a single running agent conversation. ORGII manages multiple con Every session belongs to one of three dispatch categories: -| Category | What it is | -| ------------ | -------------------------------------------------------------------------------------------------------------------------------------- | -| `cli_agent` | An external CLI coding-agent process (Cursor, Claude Code, Codex, Gemini CLI, Copilot, Kiro, OpenCode, …) spawned and managed by ORGII | -| `rust_agent` | ORGII's built-in Rust-native agent — the SDE Agent or OS Agent | -| `cursor_ide` | A read-only view of a Cursor IDE chat imported into ORGII (no ORGII-side process) | +| Category | What it is | +| ------------ | --------------------------------------------------------------------------------------------------------------------------------------- | +| `cli_agent` | An external CLI coding-agent process (Cursor, Claude Code, Codex, Antigravity, Copilot, Kiro, OpenCode, …) spawned and managed by ORGII | +| `rust_agent` | ORGII's built-in Rust-native agent — the SDE Agent or OS Agent | +| `cursor_ide` | A read-only view of a Cursor IDE chat imported into ORGII (no ORGII-side process) | ## CLI agents @@ -21,7 +21,7 @@ When you select `cli_agent`, ORGII spawns the chosen CLI tool as a subprocess, p | Cursor | `cursor_cli` | | Claude Code | `claude_code` | | Codex | `codex` | -| Gemini CLI | `gemini_cli` | +| Antigravity | `antigravity` | | GitHub Copilot | `copilot` | | Kiro | `kiro` | | OpenCode | `opencode` | diff --git a/docs/cursor-ide-metadata.md b/docs/cursor-ide-metadata.md new file mode 100644 index 000000000..1a4053a9a --- /dev/null +++ b/docs/cursor-ide-metadata.md @@ -0,0 +1,82 @@ +# Cursor IDE session metadata + +Reference for the metadata ORGII imports from Cursor's local store, what each +field is, and where it comes from. Verified against a real Cursor install +(2026‑07). + +## Where Cursor keeps things + +Modern Cursor uses a three‑tier layout under +`~/Library/Application Support/Cursor/User/globalStorage/` +(`%APPDATA%\Roaming\Cursor\...` on Windows, `~/.config/Cursor/...` on Linux): + +| Store | What it holds | ORGII uses it for | +| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------- | +| `conversation-search.db` | Lightweight **index**: one indexed row per conversation (`id`, `title`, `updated_at`, `is_archived`, `root_fingerprint`), `conversations_recency` index. | **Discovery + change detection.** A cheap indexed read, no blob parsing. | +| `state.vscdb` | The **content**: `cursorDiskKV` key/value table with `composerData:` (session metadata) and `bubbleId::` (messages). Can be multiple GB. | Point‑lookup `composerData:` for changed sessions; lazy bubble reads when a session is opened. | +| `~/.cursor/chats///store.db` | Newer per‑session blob store (agent‑mode subset only). | Not used — the main history is in `state.vscdb`. | + +The `conversation-search.db` `id` **is** the composerId, so +`composerData:` is a fast primary‑key lookup. + +## The pipeline (`sources/cursor_ide`) + +``` +conversation-search.db ──► discover_from_index() (indexed SELECT, ~14 ms for 1656 rows) + │ updated_at + root_fingerprint = change signature + ▼ +changed_records_from_conn() ──► only genuinely-changed sessions + │ + ▼ +state.vscdb: composerData: ──► cache_input_from_raw() (parse the changed few) + ▼ +imported_history_session_cache ──► CursorIdeSessionRow ──► SessionAggregateRecord ──► frontend Session +``` + +This is the **same incremental model the file‑based sources use** (claude_code, +codex, cline, trae…): discovery is cheap, and only changed sessions are +re‑parsed. There is no per‑restart full scan of `state.vscdb`, and no separate +on‑hover fetch — every field below rides in the session row. + +## `composerData` fields + +### Captured today (surfaced on the session) + +| Field in `composerData` | Session field | Notes | +| ------------------------------------------------------------------------------ | ----------------------------------- | -------------------------------------------------------------- | +| `name` | `name` | Session title. | +| `createdAt` | `created_at` | | +| last bubble `createdAt` / `lastUpdatedAt`; index `updated_at` wins for recency | `updated_at` | Sort key. | +| `status` | `status` | `completed` / `aborted` / … | +| `unifiedMode` | (metadata) | `agent` / `edit` / `ask`. | +| `isAgentic` | (metadata) | | +| `modelConfig.modelName` | `model` | e.g. `claude-opus-4-8`, `gpt-5.5`. | +| `contextTokensUsed` | `input_tokens` | Cursor records a single total (no in/out split). | +| `totalLinesAdded` / `totalLinesRemoved` | `lines_added` / `lines_removed` | | +| `filesChangedCount` | `files_changed` | | +| `originalFileStates` (keys with an edit marker) + newly‑created | `touched_files` | Files the session edited. | +| `trackedGitRepos[0].repoPath` (fallback `workspaceIdentifier.uri.fsPath`) | `repo_path` (+ derived `repo_name`) | The repo the chat ran in. Populated ~66/80 of recent sessions. | +| `trackedGitRepos[0].branches[0].branchName` | `branch` | Branch at the time. | +| parent `subagentComposerIds` + child `subagentInfo.parentComposerId` | `parent_session_id` / `listable` | Child is nested under its parent in the sidebar. | + +### Present but not captured + +- **`subtitle`** — Cursor's one‑line change summary (e.g. "Edited index.tsx, + index.tsx"). Redundant with `touched_files`; threading it through the shared + `SessionAggregateRecord` (no `Default`, ~24 constructors) wasn't worth it. +- **`context.selectedCommits` / `context.selectedPullRequests` / + `context.gitPRDiffSelections`** — commits/PRs a user _attached to the chat as + context_. First‑class fields, but empty in practice (0/80 sampled). These are + inputs, not outputs — Cursor does not record commits the session produced. +- `promptTokenBreakdown` (per‑category token usage), `fullConversationHeadersOnly` + (bubble list — read lazily for replay), and ~25 UI/worktree state booleans. + +## What is _not_ available anywhere in Cursor's store + +- **Commits produced by the session** — not recorded. `trackedGitRepos` tracks + repo + branch, not a commit list. +- **Linked pull requests** — only the (usually empty) attach‑as‑context slots. + +Correlating a session to the commits/PRs it produced would require joining by +repo + time window against `git log` / the GitHub API — a separate concern from +this importer. diff --git a/docs/external-history-loader-metadata.md b/docs/external-history-loader-metadata.md new file mode 100644 index 000000000..3cd4d607f --- /dev/null +++ b/docs/external-history-loader-metadata.md @@ -0,0 +1,301 @@ +# External history loader metadata matrix + +本文档记录 ORGII 当前从各个外部 AI session loader 读取并写入统一 cache 的 +metadata。它的主要用途是: + +1. 判断一个 loader 能否支持 file → AI session 的 blame 查询; +2. 区分“源数据没有”与“源数据存在、但 loader 尚未归一化”; +3. 修改 loader 时同步更新 capability matrix。 + +最后按代码验证:**2026-07-14**。 + +## Scope + +这里覆盖 `imported_history` 当前注册的 8 个 external loaders: + +- Claude Code (`claude_code`) +- Codex (`codex_app`) +- Cursor (`cursor_ide`) +- OpenCode (`opencode`) +- Windsurf (`windsurf`) +- WorkBuddy / CodeBuddy (`workbuddy`) +- Trae (`trae`) +- Cline (`cline`) + +`orgii_cli_sessions` 和 `orgii_rust_agents` 是 ORGII 自有 session source,不属于 +external history loader,因此不在此表中。 + +## Unified cache schema + +所有 loader 最终写入 `ImportedHistoryCacheInput` / +`imported_history_session_cache`。统一字段分成四组: + +| 类别 | 字段 | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------- | +| Identity | `source`, `source_session_id`, `session_id` | +| Source/change detection | `source_path`, `source_record_key`, `source_mtime_ms`, `source_size_bytes`, `source_fingerprint`, `parser_version` | +| Session | `name`, `created_at_ms`, `updated_at_ms`, `model`, `input_tokens`, `output_tokens`, `repo_path`, `branch` | +| Impact/hierarchy | `files_changed`, `lines_added`, `lines_removed`, `touched_files`, `listable`, `parent_session_id`, `source_metadata_json` | + +注意:统一 cache 当前没有 recorded cost、estimated cost、commit 或 pull request 字段。 +价格估算属于下游计算,不是 loader 原始 metadata。 + +## Capability matrix + +图例: + +- ✅:loader 当前会写入统一 cache。 +- ◐:会写入,但值的语义或精度有限。 +- 🟡:源 transcript/tool data 中可推导,但当前 loader 没有写入。 +- ❓:产品能力存在,但当前读取的本地存储尚未验证出稳定映射。 +- —:当前没有可靠来源或不支持。 + +所有 loader 都会写入 session id、name、created/updated time 和 source provenance; +下表只比较有差异的能力。 + +| Loader | Repo path | Branch | Model | Token split | Touched files | `+/-` lines | Parent/subagent | Source-specific metadata | +| ----------- | ------------------------- | ----------------- | ------------------------------------ | -------------------------------------- | ----------------------------- | ------------------------------------------ | -------------------------------- | ------------------------ | +| Claude Code | ✅ `cwd` | ✅ `gitBranch` | ✅ | ✅ input/output,input 含 cache tokens | ✅ | ✅ structured patch;旧记录启发式 fallback | ✅ sidechain → parent | — | +| Codex | ✅ turn `cwd` | — | ✅ | ✅ input/output | ✅ | ✅ successful `patch_apply_end` | ✅ subagent thread → parent | — | +| Cursor | ✅ tracked repo/workspace | ✅ tracked branch | ✅ | ◐ 单一 `contextTokensUsed` 写入 input | ✅ `originalFileStates` | ✅ Cursor 汇总计数 | ✅ composer child → parent | ✅ status、agentic、mode | +| OpenCode | ✅ `session.directory` | — | ✅ | ✅ 含 reasoning/cache token 分类 | ✅ edit tool parts | ◐ tool 参数/diff 启发式 | ✅ `parent_id` child → parent | — | +| Windsurf | ✅ tracked repo/workspace | ✅ tracked branch | ✅ | ◐ 单一 context token total 写入 input | ✅ tool-former edit data | ◐ tool 参数/diff 启发式 | ✅ `subagentInfo` child → parent | — | +| WorkBuddy | ✅ `cwd`/`project` | ✅ `gitBranch` | ✅ | ✅ input/output,含 cache tokens | ✅ edit tool 参数 | ◐ edit 参数启发式计数 | ✅ subagent path → parent | — | +| Trae | ◐ 从 project slug 还原 | — | ◐ 实际为 agent label,不是 LLM model | — | — | — | — | ✅ agent、current、order | +| Cline | ✅ `workspaceRoot`/`cwd` | — | ✅ model,fallback provider | ✅ input/output | ✅ child/root edit transcript | ◐ old/new tool 参数启发式 | ✅ `sessions.db` child → parent | — | + +## Direct metadata vs loader-derived metadata + +这里的“直接”特指:源存储已经有稳定字段或结构化 map,loader 只需要读取、改名或加 +session prefix。“计算”表示源里没有最终的 session-level cache 字段,需要 ORGII 遍历 +event/tool records、筛选、聚合或解析 diff 后生成。“启发式”表示计算输入是 requested tool +arguments,不保证等于最终成功写入磁盘的结果。 + +### AI blame and subagent provenance + +| Loader | `touched_files` 来源 | `lines_added/removed` 来源 | Parent/subagent 来源 | +| ----------- | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | ----------------------------------------------------------------- | +| Claude Code | **结构化计算**:聚合 `toolUseResult.filePath`;旧记录从 Edit/MultiEdit/Write path 聚合 | **结构化计算**:统计 `structuredPatch`;旧记录为 old/new 参数启发式 | **直接 metadata**:`isSidechain` + parent `sessionId` | +| Codex | **直接 structured event**:成功 `patch_apply_end.changes` 的 map keys | **结构化计算**:解析每个 change 的 `unified_diff` | **直接 metadata**:`parent_thread_id` / thread-spawn parent | +| Cursor | **直接 metadata + filter**:`originalFileStates` map keys | **直接 metadata**:`totalLinesAdded` / `totalLinesRemoved` | **直接 metadata**:`subagentComposerIds` + `parentComposerId` | +| OpenCode | **需要计算**:筛选当前 session 的 write/edit/patch parts,聚合 input path | **计算/启发式**:有 patch 时解析 diff,否则统计 old/new/content tool args | **直接 metadata**:`session.parent_id`;loader 只做循环/孤儿校验 | +| Windsurf | **需要计算**:筛选当前 composer 的 edit/write tool-former records,聚合 params path | **计算/启发式**:解析 patch 或统计 before/after content | **直接 metadata**:`subagentInfo.parentComposerId` | +| WorkBuddy | **需要计算**:筛选 child/root JSONL 中的 edit/write/apply-patch calls,聚合 path | **启发式计算**:统计 old/new/content/edits tool args | **路径推导**:`/subagents/agent-*.jsonl`;child id 为直接 | +| Trae | **没有可靠来源** | **没有可靠来源** | **没有可靠来源** | +| Cline | **需要计算**:从每个 DB row 自己的 transcript 聚合 `editor.path` | **启发式计算**:统计 `old_text` / `new_text`,失败 result 不计入 | **直接 DB metadata**:`is_subagent` + `parent_session_id` | + +因此,只有 Cursor 已经在 session/composer metadata 中同时提供 file/line 汇总;Codex +提供 authoritative applied-event file map,但仍需解析 diff 计算行数。Claude Code 有 +authoritative structured patch,但需要跨 tool results 聚合成 session totals。OpenCode、 +Windsurf、WorkBuddy、Cline 都没有现成的 session-level touched-file list,必须由 loader +从各自的 tool records 计算。 + +### General session metadata provenance + +| Loader | 源里直接存在 | ORGII 需要计算/归一化 | 启发式或缺失 | +| ----------- | ---------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ | +| Claude Code | session id、timestamp、`cwd`、`gitBranch`、model、usage、title/summary、sidechain fields | title fallback chain、token category 求和、turn/tool records 聚合 | 旧 edit 记录的 line totals | +| Codex | thread id、timestamp、turn `cwd`/model、`total_token_usage`、parent thread、patch result | title fallback chain、选择最新 total usage、patch diff 聚合 | branch 缺失;旧 rollout 使用 tool-call fallback | +| Cursor | composer name/time/status/model/mode、context token total、repo/branch、impact totals、subagent fields | workspace fallback、URI → path、过滤真正有 edit marker 的 file states | input/output token split 缺失 | +| OpenCode | `session` 表的 id/title/directory/model/times/token columns/`parent_id`,以及 part tool state | model JSON 解析、reasoning/cache token 分类求和、part impact 聚合、container/mirror 校验 | 无 patch 时 line totals 是 tool-argument heuristic | +| Windsurf | composer name/time/status/model/context total/repo/branch、`subagentInfo`、bubble tool-former data | workspace fallback、tool params/result 归一化、composer impact 聚合 | input/output split 缺失;无 diff 时 line totals 启发式 | +| WorkBuddy | JSONL timestamp/model/usage/`cwd`/project/branch、child 内嵌 `sessionId` | title fallback、兼容多种 token 字段并求和、tool impact 聚合、从目录布局推导 parent | line totals 是 requested args heuristic | +| Trae | summary topic/time、agent id、current/order | 从 project slug 尽力还原 repo path、把 agent id 转成显示 label | model/tokens/branch/impact/parent 缺失 | +| Cline | DB session/parent/status/time/model/workspace/messages path,sidecar title/prompt/usage,transcript events | DB → sidecar → transcript fallback chain、aggregate usage fallback、每个 root/child transcript 的 impact | branch 缺失;line totals 是 editor args heuristic | + +## Loader details + +### Claude Code + +主要来源:`~/.claude/projects/**/*.jsonl`,并读取相邻 session title index。 + +当前归一化: + +- Name:custom title → AI title → summary/index title → first prompt → id。 +- Time:transcript timestamps,缺失时退回文件 mtime。 +- Workspace:`cwd`、`gitBranch`。 +- Usage:assistant message usage;input 包含普通、cache-read、cache-creation + tokens,output 单独累计。 +- Impact:优先使用 `toolUseResult.structuredPatch` 的结构化 diff;旧记录没有 + structured patch 时,退回 Edit/MultiEdit/Write 参数启发式。 +- Hierarchy:`isSidechain=true` 且 `sessionId` 指向另一个 session 时,写入 + `parent_session_id`。因此 sidebar 可按主 session 折叠 subagent。 + +AI blame 状态:**可直接使用 `touched_files`**。新格式的 line stats 接近实际 +applied diff;旧格式 fallback 只代表工具参数中的文本行数。 + +### Codex + +主要来源:`~/.codex/sessions/**/*.jsonl`,标题从 `session_index.jsonl` 或 +session metadata 读取。 + +当前归一化: + +- Name:session index/thread name → session metadata title → first prompt → id。 +- Time、model、repo path:rollout timestamp 和 turn context (`cwd`, `model`)。 +- Usage:最新 `total_token_usage.input_tokens/output_tokens`。 +- Impact:优先读取成功的 `patch_apply_end.changes[path].unified_diff`;这能覆盖 + `apply_patch`、exec 包装的 patch 等路径。旧 rollout 退回 apply-patch tool-call + 解析。 +- Hierarchy:从 subagent `session_meta` 的 `parent_thread_id`、 + `source.subagent.thread_spawn.parent_thread_id` 等字段解析 parent。 + +AI blame 状态:**可直接使用 `touched_files`**,且当前是各 loader 中较强的 +authoritative applied-patch 信号。当前没有 branch metadata。 + +### Cursor + +主要来源:`conversation-search.db` 负责 discovery/change detection, +`state.vscdb` 的 `composerData:` 负责 metadata,bubble 在打开 session 时懒加载。 +更完整的存储说明见 [Cursor IDE session metadata](./cursor-ide-metadata.md)。 + +当前归一化: + +- Name/time/status/model/mode:composer metadata;index `updated_at` 是排序时的 + authoritative recency。 +- Workspace:`trackedGitRepos[0]`,fallback 到 `workspaceIdentifier`。 +- Usage:`contextTokensUsed` 是单一总数,统一写入 `input_tokens`,没有可靠的 + input/output split。 +- Impact:`totalLinesAdded`、`totalLinesRemoved`、`filesChangedCount`; + `touched_files` 来自 `originalFileStates` 中带 edit marker 或 newly-created 的文件。 +- Hierarchy:主 composer 的 `subagentComposerIds` 用于发现 child,child 的 + `subagentInfo.parentComposerId` 用于最终归属。child 不进入 root list,由 sidebar + 的通用 child-session flow 折叠显示。 +- Extra:`source_metadata_json` 保存 `status`、`isAgentic`、`unifiedMode`。 + +AI blame 状态:**可直接使用 `touched_files`**。Line/file totals 是 Cursor 自己的 +session 汇总;`touched_files.len()` 不应被假定永远等于 `filesChangedCount`。 + +### OpenCode + +主要来源:OpenCode 的 `opencode.db`,读取 `session`、message 和 part 表。 + +当前归一化: + +- Name/time/model/repo:`session.title`、`time_created/time_updated`、model JSON、 + `directory`。 +- Usage:input 加上 cache read/write;output 加上 reasoning tokens。 +- Hierarchy:读取 `parent_id`,但当前逻辑只将有效 container parent/mirror 关系 + 映射成 `parent_session_id`,并处理循环、缺失 parent 和 ORGII-managed mirror。 +- Impact:从当前 session 自己的 write/edit/patch/apply-patch tool parts 提取路径; + patch 文本优先统计 unified diff,否则以 old/new/content 参数估算行数。失败的 edit + 不计入。 + +AI blame 状态:**可直接使用 `touched_files`**。`parent_id` 对应的 child run 使用自己 +的 part stream 计算 impact,因此不会把 child 修改重复归到 parent。 + +### Windsurf + +主要来源:Windsurf `User/globalStorage/state.vscdb` 的 composer/bubble 数据。 + +当前归一化: + +- Name/time/status/model、repo、branch 和单一 `contextTokensUsed`。 +- Hierarchy:`subagentInfo.parentComposerId` 写入 `parent_session_id`;child 不进入根 + list,由 sidebar 的通用 child-session flow 折叠显示。 +- Impact:从每个 composer 自己的 edit/write/apply-patch `toolFormerData` 提取 path; + 可获得 before/after content 时估算行数,并忽略失败状态。 + +AI blame 状态:**可直接使用 `touched_files`**。Impact 按 composer 计算,subagent +修改保留在 child row。 + +### WorkBuddy / CodeBuddy + +主要来源:`~/.workbuddy/{projects,sessions,history.jsonl}`、 +`~/.codebuddy/...` 和 CodeBuddyExtension JSONL。 + +当前归一化: + +- Name:AI title/display → first user prompt → file stem。 +- Time/model/repo/branch:JSONL timestamp、message model、`cwd`/`project`、 + `gitBranch`。 +- Usage:兼容 input/output、prompt/completion 和 cache token 字段。 +- Impact:识别 Edit、MultiEdit、Write、edit_file、write_file、apply_patch 等 tool, + 但只有参数包含结构化 path 字段时才收集文件;line totals 通过 + old/new/content/edits 文本行数估算。 +- Hierarchy:已观察到 `/subagents/agent-*.jsonl` 布局,目录可提供 + parent id,child JSONL 自身有独立 `sessionId`。Discovery 会导入这些 `agent-*` + 文件,优先以 child 内嵌 `sessionId` 作为 source id,并将目录 parent id 标准化为 + `parent_session_id`。 + +AI blame 状态:**可直接使用 `touched_files`**。文件集合通常可靠;line totals 是 +requested tool arguments 的启发式统计,不等同于成功 applied diff。Child transcript +独立计算,不会与 parent 混合。 + +### Trae + +主要来源:`~/.trae-cn/memory/projects/**/session_memory_*.jsonl` 和 +`~/.trae/memory/projects/**`。明文文件只有 turn summary;完整 transcript 位于 +SQLCipher 加密的 `ModularData/ai-agent/database.db`,当前不解密。 + +当前归一化: + +- Name:`topics.md` 中的 session topic → first summary intent → id。 +- Time:`message_summary_time`;creation time 缺失时还可从 Mongo ObjectId-style + session id 回退。 +- Repo:从 project directory slug 尽力还原 filesystem path。 +- “Model”:从 VS Code `state.vscdb` index 读取 agent id,并转换为类似 + `Solo Agent` 的 label;它不是底层 LLM model。 +- Extra:`source_metadata_json` 保存 agent、是否 current、Trae list order。 +- Tokens、branch、impact、parent 均为空。 + +AI blame 状态:**没有可靠结构化信号**。Summary 的 `actions` 可能偶尔提到文件, +但属于自然语言,不能作为稳定的 blame index。要获得可靠文件列表,需要解密完整 DB、 +找到新的明文事件源,或通过 repo/time correlation 另行推断。 + +### Cline + +主要来源:`~/.cline/data/db/sessions.db` 作为 discovery/hierarchy index,按每行的 +`messages_path` 读取 root 或 child transcript;旧安装没有 DB 时,fallback 到 +`~/.cline/data/sessions//.messages.json`。Root 仍读取同目录 `.json` +sidecar。 + +当前归一化: + +- Name:sidecar title → DB `metadata_json.title` → sidecar/DB prompt → first user text → id。 +- Time/model/repo/usage:优先 sidecar/transcript,并以 DB 的 started/updated、model、 + provider、workspace/cwd、aggregate usage 补缺。 +- Hierarchy:DB 的 `is_subagent=1` + `parent_session_id` 直接建立 child relation。 +- Impact:每个 DB row 指向自己的 transcript;从 `editor` old/new/path 参数生成 + `touched_files` 和启发式行数。 +- Branch 为空。 + +AI blame 状态:**可直接使用 `touched_files`**。已用真实 Cline spawn 验证:root 与 +`__agent_` child 是两行独立 session,child 有独立 `messages_path` 并明确 +指向 root;因此 subagent-level blame 不依赖 tool-name 推断。 + +## AI blame readiness and next work + +如果 blame 的最小定义是“给定文件,列出修改过它的 AI sessions”,当前优先级为: + +1. **Ready**:Claude Code、Codex、Cursor、OpenCode、Windsurf、WorkBuddy、Cline。 +2. **Blocked on reliable source data**:Trae。 + +这里的 Ready 表示 loader 已经写入可查询的 `touched_files`,不代表历史记录覆盖率 +或路径格式已经在所有版本、所有 edit tool 上达到 100%。 + +Subagent/session hierarchy 是另一条独立能力轴: + +1. **已写入 parent relation**:Claude Code、Codex、Cursor、OpenCode、Windsurf、 + WorkBuddy、Cline。 +2. **当前没有可靠信号**:Trae。 + +索引层应该以标准化后的 `touched_files` 为唯一查询接口,而不是让 blame feature +重新理解每种 transcript。每个新 collector 至少需要: + +1. 只记录实际 edit/write/patch 操作,避免把 read/search 路径算作修改; +2. 路径相对 `repo_path` 归一化,并处理 URI、绝对路径和平台分隔符; +3. 对 session 内文件去重; +4. 区分 authoritative applied diff 与 tool-argument heuristic; +5. bump 对应 loader 的 metadata parser version,让旧 cache 自动重建; +6. 添加 fixture/unit test,覆盖 modified、created、deleted、rename 和 failed edit。 + +## Maintenance rule + +修改任何 external loader 的 `session_meta_to_cache_input`、Cursor 的 +`cache_input_from_raw`,或新增 `ImportedHistoryCacheInput` 字段时,应在同一个 PR 中更新: + +1. 本文 capability matrix; +2. 对应 loader detail; +3. AI blame readiness 分组; +4. parser version 和 metadata/cache tests(如果字段会改变已有 cache row)。 diff --git a/docs/frontend-ui-audit-2026-07-01/SourceControlScopeToolbar.md b/docs/frontend-ui-audit-2026-07-01/SourceControlScopeToolbar.md new file mode 100644 index 000000000..d4b3c9631 --- /dev/null +++ b/docs/frontend-ui-audit-2026-07-01/SourceControlScopeToolbar.md @@ -0,0 +1,151 @@ +# Frontend UI Audit — SourceControlScopeToolbar + +**File:** `src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/tabs/SourceControlScopeToolbar.tsx` (355 LOC) +**Date:** 2026-07-01 +**Auditor:** Cursor agent (frontend-ui-audit skill) + +## D1 — Raw HTML vs Design System + +| Line | Element | Verdict | Reason | Suggested change | +| ---- | ------------------------ | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------- | +| 119 | ` )} {shouldShowCopyButton && ( - )} {isPreviewable && ( @@ -407,6 +403,47 @@ const ChatCodeBlock: React.FC = memo( )} + {shouldShowFloatingToolbar && ( +
+
+ {shouldShowOpenButton && ( + + )} + {shouldShowCopyButton && ( + + )} +
+
+ )} + {hasContent && !isCollapsed && !(isLoading && !code) && (
void; + toolUsage?: ToolUsageMetadata; } const CreatePlanCard: React.FC = memo( @@ -130,6 +133,7 @@ const CreatePlanCard: React.FC = memo( onOpenPreview, collapsed = false, onCollapse, + toolUsage, }) => { const { t } = useTranslation("sessions"); const activeSessionId = useAtomValue(activeSessionIdAtom); @@ -189,6 +193,21 @@ const CreatePlanCard: React.FC = memo( const ready = surfaceState?.readyForReview ?? (idMatch && !isStreaming && effectiveApprovalStatus === "pending"); + const autoApproveAt = idMatch + ? (state.current?.autoApproveAt ?? null) + : null; + const [nowMs, setNowMs] = useState(() => Date.now()); + + useEffect(() => { + if (!autoApproveAt || submitting) return; + const timer = window.setInterval(() => setNowMs(Date.now()), 1000); + return () => window.clearInterval(timer); + }, [autoApproveAt, submitting]); + + const autoApproveRemaining = + autoApproveAt && !submitting && ready + ? Math.max(0, Math.ceil((autoApproveAt - nowMs) / 1000)) + : null; const sessionIsWorking = sessionId === activeSessionId && (runtimeStatus === "running" || runtimeStatus === "installing"); @@ -206,6 +225,12 @@ const CreatePlanCard: React.FC = memo( isStreaming, ready ); + const countdownLabel = + autoApproveRemaining !== null && ready && !isEditing + ? t("chat.autoExecuteCountdown", { + seconds: autoApproveRemaining, + }) + : null; const handlePreviewNavigate = onOpenPreview ?? (eventId ? handleLocate : undefined); @@ -376,66 +401,68 @@ const CreatePlanCard: React.FC = memo( title={t("planDoc.collapse")} /> ) : null; - const planActions = - ownsActions || collapseButton ? ( -
event.stopPropagation()} - > - {ownsActions && ( - <> - {ready && !isEditing && ( - - )} - {ready && ( - - )} - {isEditing ? ( - - ) : ( - - )} - - )} + const headerRight = + toolUsage || collapseButton ? ( +
+ {toolUsage && } {collapseButton}
- ) : null; + ) : undefined; + const planActions = ownsActions ? ( +
event.stopPropagation()} + > + {countdownLabel && ( + + {countdownLabel} + + )} + {ready && !isEditing && ( + + )} + {ready && ( + + )} + {isEditing ? ( + + ) : ( + + )} +
+ ) : null; const planIcon = getToolIcon("create_plan", { size: PLAN_ICON_SIZE }); return ( @@ -456,7 +483,7 @@ const CreatePlanCard: React.FC = memo( onNavigate={handlePreviewNavigate} onMouseEnter={handleHeaderMouseEnter} onMouseLeave={handleHeaderMouseLeave} - rightContent={planActions} + rightContent={headerRight} > = memo( - {displayTitle} + {countdownLabel + ? `${displayTitle} · ${countdownLabel}` + : displayTitle} @@ -505,6 +538,7 @@ const CreatePlanCard: React.FC = memo( )}
))} + {planActions}
); } diff --git a/src/engines/ChatPanel/blocks/DiffBlock/index.tsx b/src/engines/ChatPanel/blocks/DiffBlock/index.tsx index aca4ded01..dbba869de 100644 --- a/src/engines/ChatPanel/blocks/DiffBlock/index.tsx +++ b/src/engines/ChatPanel/blocks/DiffBlock/index.tsx @@ -31,6 +31,7 @@ import { getFileName } from "@src/util/file/pathUtils"; import { useChatHistoryDisplayMode } from "../../ChatHistory/chatDisplayModeContext"; import ChatCodeBlock from "../CodeBlock"; import EventFileHoverPreview from "../EventFileHoverPreview"; +import ToolUsageBadge from "../ToolCallBlock/ToolUsageBadge"; import { EventBlockHeader, EventBlockHeaderIcon, @@ -210,6 +211,7 @@ interface CompactSegmentViewProps { status: EventStatus; isLoading: boolean; isNewFile?: boolean; + toolUsage?: UniversalEventProps["toolUsage"]; } const CompactSegmentView: React.FC = ({ @@ -218,6 +220,7 @@ const CompactSegmentView: React.FC = ({ status, isLoading, isNewFile, + toolUsage, }) => { const { t } = useTranslation("sessions"); const displayTitle = @@ -230,10 +233,12 @@ const CompactSegmentView: React.FC = ({ ? decodedContent.split("\n").length : (segment.linesAdded ?? 0); const linesRemoved = segment.linesRemoved ?? 0; - const hasInfo = linesAdded > 0 || linesRemoved > 0 || segment.isDeleted; + const isCompletedDeletion = segment.isDeleted && status !== "running"; + const hasInfo = linesAdded > 0 || linesRemoved > 0 || isCompletedDeletion; + const compactToolName = segment.isDeleted ? "delete_file" : "edit_file"; const compactLabelState = status === "running" ? "compact_running" : "compact_done"; - const compactTitle = useToolLabelText("edit_file", compactLabelState); + const compactTitle = useToolLabelText(compactToolName, compactLabelState); const { isHeaderHovered, @@ -242,13 +247,13 @@ const CompactSegmentView: React.FC = ({ handleLocate, } = useBlockHeader({ eventId }); - const editIcon = useMemo( + const activityIcon = useMemo( () => - getToolIcon("edit_file", { + getToolIcon(compactToolName, { size: SESSION_UI_TOKENS.ICON.SIZE_SM, className: "text-text-2", }), - [] + [compactToolName] ); const content = ( @@ -260,9 +265,12 @@ const CompactSegmentView: React.FC = ({ onNavigate={handleLocate} onMouseEnter={handleHeaderMouseEnter} onMouseLeave={handleHeaderMouseLeave} + rightContent={ + toolUsage ? : undefined + } > = ({ additions={linesAdded} deletions={linesRemoved} variant="plain" - className="translate-y-px gap-0" + className="translate-y-px" + valueClassName="font-normal" + reserveValueWidth={false} /> - {segment.isDeleted && ( + {isCompletedDeletion && ( {t("tools.deleted")} )} @@ -324,7 +334,9 @@ function isDeleteFileEvent(props: UniversalEventProps): boolean { const DeleteFileView: React.FC = (props) => { const { t } = useTranslation("sessions"); + const displayMode = useChatHistoryDisplayMode(); const { status, title } = props; + const isLoading = props.showActiveEventPainting === true; let filePath = ""; let fileName = ""; @@ -343,13 +355,36 @@ const DeleteFileView: React.FC = (props) => { const displayTitle = getFileName(filePath) || fileName || "file"; + if (status === "failed") { + return ; + } + + if (displayMode === "compact") { + return ( + + ); + } + if (status === "running") { const deleteIcon = getToolIcon("delete_file", { size: SESSION_UI_TOKENS.ICON.SIZE_SM, }); - const isLoading = props.showActiveEventPainting === true; return ( - + + ) : undefined + } + > {title} @@ -358,10 +393,6 @@ const DeleteFileView: React.FC = (props) => { ); } - if (status === "failed") { - return ; - } - return ( = (props) => { onNavigate={handleLocate} onMouseEnter={handleHeaderMouseEnter} onMouseLeave={handleHeaderMouseLeave} + rightContent={ + props.toolUsage ? ( + + ) : undefined + } > = (props) => { if (status === "running" && !hasStreamingContent(segments)) { return ( - + + ) : undefined + } + > {title} @@ -474,6 +518,7 @@ const EditView: React.FC = (props) => { status={status} isLoading={isLoading} isNewFile={isNewFile} + toolUsage={segmentIndex === 0 ? props.toolUsage : undefined} /> ))} diff --git a/src/engines/ChatPanel/blocks/EventFileHoverPreview.tsx b/src/engines/ChatPanel/blocks/EventFileHoverPreview.tsx index cefc14ed3..13bc768c8 100644 --- a/src/engines/ChatPanel/blocks/EventFileHoverPreview.tsx +++ b/src/engines/ChatPanel/blocks/EventFileHoverPreview.tsx @@ -22,6 +22,8 @@ const EventFileHoverPreview: React.FC = ({ repoPath={repoPath} as="div" display="block" + placement="bottom" + showDelayMs={750} > {children} diff --git a/src/engines/ChatPanel/blocks/ExploreBlock/index.tsx b/src/engines/ChatPanel/blocks/ExploreBlock/index.tsx index 9ab42cdd2..6a81a142b 100644 --- a/src/engines/ChatPanel/blocks/ExploreBlock/index.tsx +++ b/src/engines/ChatPanel/blocks/ExploreBlock/index.tsx @@ -10,7 +10,9 @@ import { useTranslation } from "react-i18next"; import FileTypeIcon from "@src/components/FileTypeIcon"; import { getToolIcon } from "@src/config/toolIcons"; import ChatCodeBlock from "@src/engines/ChatPanel/blocks/CodeBlock"; +import type { ToolUsageMetadata } from "@src/engines/SessionCore/core/types"; +import ToolUsageBadge from "../ToolCallBlock/ToolUsageBadge"; import { ComposerStackListRow, EVENT_BLOCK_TRANSPARENT_EXPANDED_SHELL_CLASSES, @@ -75,6 +77,7 @@ export interface ExploreBlockProps { toolName?: string; /** Action-level hint for icon selection (e.g. `"ls"` vs `"tree"`). */ action?: string; + toolUsage?: ToolUsageMetadata; } const TreeConnector: React.FC<{ isLast: boolean }> = ({ isLast }) => ( @@ -145,6 +148,7 @@ const ExploreBlock: React.FC = React.memo( toolName = "list_dir", action, hideEntryIcons = false, + toolUsage, }) => { void isFailed; const { t } = useTranslation("sessions"); @@ -158,7 +162,7 @@ const ExploreBlock: React.FC = React.memo( [toolName, action] ); const { - isCollapsed: isExpanded, + isCollapsed, isHeaderHovered, handleHeaderClick, handleHeaderMouseEnter, @@ -167,7 +171,7 @@ const ExploreBlock: React.FC = React.memo( } = useBlockHeader({ defaultCollapsed, eventId, - collapseAllValue: false, + collapseAllValue: true, preserveDefaultOnExpand: true, }); @@ -367,17 +371,20 @@ const ExploreBlock: React.FC = React.memo( return (
: undefined + } > = React.memo( )} - {isExpanded && hasContent && !isLoading && ( + {!isCollapsed && hasContent && !isLoading && (
{treeContentJSX || flatContentJSX} diff --git a/src/engines/ChatPanel/blocks/GlobBlock/index.tsx b/src/engines/ChatPanel/blocks/GlobBlock/index.tsx index 500f7b8ef..82062a2bf 100644 --- a/src/engines/ChatPanel/blocks/GlobBlock/index.tsx +++ b/src/engines/ChatPanel/blocks/GlobBlock/index.tsx @@ -9,7 +9,9 @@ import React from "react"; import FileTypeIcon from "@src/components/FileTypeIcon"; import { getToolIcon } from "@src/config/toolIcons"; +import type { ToolUsageMetadata } from "@src/engines/SessionCore/core/types"; +import ToolUsageBadge from "../ToolCallBlock/ToolUsageBadge"; import { EventBlockHeader, EventBlockHeaderIcon, @@ -31,10 +33,11 @@ export interface GlobBlockProps { * `useLifecycleLabels("code_search", "find_files")` (or `"glob_file_search"`). */ title: string; + toolUsage?: ToolUsageMetadata; } const GlobBlock: React.FC = React.memo( - ({ pattern, isLoading = false, eventId, title }) => { + ({ pattern, isLoading = false, eventId, title, toolUsage }) => { const { isHeaderHovered, handleHeaderMouseEnter, @@ -55,6 +58,9 @@ const GlobBlock: React.FC = React.memo( onNavigate={handleLocate} onMouseEnter={handleHeaderMouseEnter} onMouseLeave={handleHeaderMouseLeave} + rightContent={ + toolUsage ? : undefined + } > = React.memo( - ({ dirPath, isLoading = false, eventId, title, targetPath }) => { + ({ dirPath, isLoading = false, eventId, title, targetPath, toolUsage }) => { const { isHeaderHovered, handleHeaderMouseEnter, @@ -67,6 +70,9 @@ const ListDirBlock: React.FC = React.memo( onNavigate={handleLocate} onMouseEnter={handleHeaderMouseEnter} onMouseLeave={handleHeaderMouseLeave} + rightContent={ + toolUsage ? : undefined + } > = ({ isLoading = false, eventId, title, + toolUsage, }) => { const hasBody = Boolean(agentName || resultText); @@ -171,6 +175,9 @@ const ManageAgentDefBlock: React.FC = ({ onNavigate={handleLocate} onMouseEnter={handleHeaderMouseEnter} onMouseLeave={handleHeaderMouseLeave} + rightContent={ + toolUsage ? : undefined + } > = memo( eventId, sessionId, payloadRefs, + toolUsage, }) => { const rows = useMemo( () => buildRows(action, args, result), @@ -146,6 +152,9 @@ const ManageCodeMapBlock: React.FC = memo( onNavigate={handleLocate} onMouseEnter={handleHeaderMouseEnter} onMouseLeave={handleHeaderMouseLeave} + rightContent={ + toolUsage ? : undefined + } > "'`\])}]+/gi; -const TRAILING_REFERENCE_PUNCTUATION_PATTERN = /[.,;:!?]+$/; const MAX_REFERENCE_CARDS = 4; export type MessageReferenceKind = @@ -42,10 +41,12 @@ function stripFencedCodeBlocks(content: string): string { .join("\n"); } +function stripInlineCodeSpans(content: string): string { + return content.replace(/(`+)[^\n]*?\1/g, ""); +} + function normalizeUrlCandidate(candidate: string): string | null { - return normalizeHttpUrlCandidate( - candidate.replace(TRAILING_REFERENCE_PUNCTUATION_PATTERN, "") - ); + return normalizeHttpUrlCandidate(candidate, { stripTextBoundaries: true }); } function isUrlCitedInParentheses( @@ -118,31 +119,67 @@ function shortSessionIdLabel(sessionId: string): string { return `${sessionId.slice(0, uuidStart)}${sessionId.slice(uuidStart, uuidStart + 8)}…`; } +const SESSION_PILL_REFERENCE_PATTERN = /([^\n[]+?)\s*\[session:([^\]]+)\]/g; + +function lastTokenLabel(rawLabel: string): string { + const trimmed = rawLabel.trim(); + const lastSpaceIdx = trimmed.search(/\s[^\s]*$/); + return lastSpaceIdx >= 0 ? trimmed.slice(lastSpaceIdx + 1).trim() : trimmed; +} + +function makeSessionReferenceItem( + sessionId: string, + title?: string +): MessageReferenceItem { + return { + kind: "session", + value: sessionId, + title: title?.trim() || shortSessionIdLabel(sessionId), + subtitle: sessionId, + sessionId, + }; +} + +function addReference( + item: MessageReferenceItem, + references: MessageReferenceItem[], + seen: Set +): boolean { + const key = makeReferenceKey(item); + if (seen.has(key)) return references.length >= MAX_REFERENCE_CARDS; + seen.add(key); + references.push(item); + return references.length >= MAX_REFERENCE_CARDS; +} + export function extractMessageReferences( content: string, excludeUrls?: ReadonlySet ): MessageReferenceItem[] { - const searchableContent = stripFencedCodeBlocks(content); + const searchableContent = stripInlineCodeSpans( + stripFencedCodeBlocks(content) + ); const references: MessageReferenceItem[] = []; const seen = new Set(); + for (const match of searchableContent.matchAll( + SESSION_PILL_REFERENCE_PATTERN + )) { + if ( + addReference( + makeSessionReferenceItem(match[2], lastTokenLabel(match[1])), + references, + seen + ) + ) + return references; + } + for (const match of searchableContent.matchAll( createSessionIdTextPattern() )) { - const sessionId = match[0]; - const item: MessageReferenceItem = { - kind: "session", - value: sessionId, - title: shortSessionIdLabel(sessionId), - subtitle: sessionId, - sessionId, - }; - const key = makeReferenceKey(item); - if (!seen.has(key)) { - seen.add(key); - references.push(item); - } - if (references.length >= MAX_REFERENCE_CARDS) return references; + if (addReference(makeSessionReferenceItem(match[0]), references, seen)) + return references; } for (const artifact of parseGitArtifactsFromText(searchableContent)) { diff --git a/src/engines/ChatPanel/blocks/MessageReferenceCards.tsx b/src/engines/ChatPanel/blocks/MessageReferenceCards.tsx index e617bc1d1..f90ff25a7 100644 --- a/src/engines/ChatPanel/blocks/MessageReferenceCards.tsx +++ b/src/engines/ChatPanel/blocks/MessageReferenceCards.tsx @@ -8,11 +8,9 @@ import { GitCommitHorizontal, Globe, } from "lucide-react"; -import React, { useCallback, useEffect, useMemo, useState } from "react"; +import React, { useCallback, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { getGitCommits } from "@src/api/http/git"; -import type { GitCommitInfo } from "@src/api/http/git/types"; import Button from "@src/components/Button"; import Dropdown from "@src/components/Dropdown"; import { openUrlInBrowserApp } from "@src/components/MarkDown/markdownUtils"; @@ -29,14 +27,12 @@ import { simulatorSelectedAppAtom, stationModeAtom, } from "@src/store/ui/simulatorAtom"; -import { activeWorkspaceRootPathAtom } from "@src/store/workspace"; import { copyText } from "@src/util/data/clipboard"; import { SESSION_REFERENCE_FILE_MANAGER_REVEAL_KEYS, getFileManagerRevealLabelKey, } from "@src/util/platform/fileManagerLabels"; import { resolveSessionIconId } from "@src/util/session/sessionDispatch"; -import { formatRelativeTime } from "@src/util/time/formatRelativeTime"; import { openFileInEditor } from "@src/util/ui/openFileInEditor"; import { @@ -46,92 +42,15 @@ import { resolveOpenPath, } from "./MessageReferenceCards.helpers"; -const COMMIT_METADATA_LOOKUP_LIMIT = 200; - function stopReferenceCardClick(event: React.MouseEvent) { event.stopPropagation(); } -function commitMatchesReference( - commit: GitCommitInfo, - item: MessageReferenceItem -): boolean { - if (item.kind !== "git_commit" || !item.sha) return false; - const itemSha = item.sha.toLowerCase(); - const commitSha = commit.sha.toLowerCase(); - return commitSha.startsWith(itemSha) || itemSha.startsWith(commitSha); -} - -function mergeCommitMetadata( - item: MessageReferenceItem, - commit: GitCommitInfo -): MessageReferenceItem { - if (item.kind !== "git_commit") return item; - const shortSha = commit.short_sha || item.shortSha || item.sha; - const authorName = commit.author?.name; - const authorDate = commit.author?.date; - const metaParts = [ - shortSha, - authorName, - authorDate ? formatRelativeTime(authorDate, "nano") : undefined, - ].filter(Boolean); - return { - ...item, - value: commit.sha, - title: commit.summary || item.title, - subtitle: metaParts.join(" · "), - sha: commit.sha, - shortSha, - authorName, - authorDate, - }; -} - -function useCommitMetadataReferences( - references: MessageReferenceItem[], - repoPath: string | undefined -): MessageReferenceItem[] { - const [metadataState, setMetadataState] = useState<{ - repoPath: string; - commits: GitCommitInfo[]; - } | null>(null); - - useEffect(() => { - let cancelled = false; - const commitReferences = references.filter( - (item) => item.kind === "git_commit" && item.sha - ); - if (!repoPath || commitReferences.length === 0) return; - - async function loadCommitMetadata() { - const result = await getGitCommits({ - repo_id: repoPath ?? "", - repo_path: repoPath, - limit: COMMIT_METADATA_LOOKUP_LIMIT, - }); - if (cancelled || !result?.commits?.length || !repoPath) return; - setMetadataState({ repoPath, commits: result.commits }); - } - - void loadCommitMetadata(); - - return () => { - cancelled = true; - }; - }, [references, repoPath]); - - return useMemo(() => { - if (!metadataState || metadataState.repoPath !== repoPath) - return references; - return references.map((item) => { - if (item.kind !== "git_commit") return item; - const commit = metadataState.commits.find((candidate) => - commitMatchesReference(candidate, item) - ); - return commit ? mergeCommitMetadata(item, commit) : item; - }); - }, [metadataState, references, repoPath]); -} +// NOTE: commit reference cards intentionally fetch NO git metadata. The +// reference extracted from the message already carries everything the card +// shows (commit id, subject, repo name) — enriching it with author/date via +// the IDE git API meant every replay with commit references issued dozens +// of ~1s `/commits` requests for purely decorative detail. interface MessageReferenceCardProps { item: MessageReferenceItem; @@ -194,11 +113,12 @@ const SessionReferenceCard: React.FC<{ item: MessageReferenceItem }> = ({
)} diff --git a/src/engines/ChatPanel/blocks/ReadFileBlock/index.tsx b/src/engines/ChatPanel/blocks/ReadFileBlock/index.tsx index 3e0eda66e..5757bbf44 100644 --- a/src/engines/ChatPanel/blocks/ReadFileBlock/index.tsx +++ b/src/engines/ChatPanel/blocks/ReadFileBlock/index.tsx @@ -21,6 +21,7 @@ import { getFileName } from "@src/util/file/pathUtils"; import { extractSkillNameFromPath } from "@src/util/skills/skillPath"; import EventFileHoverPreview from "../EventFileHoverPreview"; +import ToolUsageBadge from "../ToolCallBlock/ToolUsageBadge"; import { EVENT_LOADING_SHIMMER_TEXT_CLASSES, EventBlockHeader, @@ -101,6 +102,11 @@ export const ReadFileBlock: React.FC = (props) => { onNavigate={handleLocate} onMouseEnter={handleHeaderMouseEnter} onMouseLeave={handleHeaderMouseLeave} + rightContent={ + props.toolUsage ? ( + + ) : undefined + } > = React.memo( - ({ pattern, isLoading = false, eventId, action, title }) => { + ({ pattern, isLoading = false, eventId, action, title, toolUsage }) => { const { isHeaderHovered, handleHeaderMouseEnter, @@ -63,6 +66,9 @@ const SearchBlock: React.FC = React.memo( onNavigate={handleLocate} onMouseEnter={handleHeaderMouseEnter} onMouseLeave={handleHeaderMouseLeave} + rightContent={ + toolUsage ? : undefined + } > = memo( lifecycleLabel, isRunning = false, isFailed = false, + toolUsage, }) => { const hasContent = !!message || @@ -124,6 +128,9 @@ const SetupRepoBlock: React.FC = memo( onClick={hasContent ? handleHeaderClick : undefined} onMouseEnter={handleHeaderMouseEnter} onMouseLeave={handleHeaderMouseLeave} + rightContent={ + toolUsage ? : undefined + } > = ({ @@ -101,6 +106,7 @@ const KillVariant: React.FC = ({ isLoading, resultMessage, title, + toolUsage, }) => { const toolIcon = getToolIcon("run_shell", { size: 14, @@ -109,7 +115,13 @@ const KillVariant: React.FC = ({ return (
- + : undefined + } + > = (props) => { isLoading={isLoading} resultMessage={resultMessage} title={props.killTitle} + toolUsage={props.toolUsage} /> ); } - // Prefer the agent-provided description (a human summary of the - // command) over the canonical lifecycle label when present — the - // lifecycle label (`props.title`) is the fallback header when there - // is no description to show. Both strings are already translated so - // this is domain logic, not an i18n fallback. + // When the agent provides a description (human summary), TerminalBlock + // promotes it to the primary title and hides the default lifecycle label. + // The parsed command symbols (npm, git, …) always remain visible either way. const trimmedDescription = description?.trim(); - const headerTitle = + const headerSubtitle = trimmedDescription && trimmedDescription.length > 0 ? trimmedDescription - : props.title; + : undefined; const runningStatusText = runtimeDisplayState.isLongForegroundWait ? t("tools.terminalWaitRunning") : undefined; @@ -245,7 +256,8 @@ const RunShellView: React.FC = (props) => { return ( = (props) => { pid={shellPid} processStatus={shellProcessStatus} onStop={handleStop} + toolUsage={props.toolUsage} + tuiRendering={props.tuiRendering} /> ); }; diff --git a/src/engines/ChatPanel/blocks/SkillBlock/index.tsx b/src/engines/ChatPanel/blocks/SkillBlock/index.tsx new file mode 100644 index 000000000..f4810b377 --- /dev/null +++ b/src/engines/ChatPanel/blocks/SkillBlock/index.tsx @@ -0,0 +1,99 @@ +/** + * SkillBlock — compact header card for rust-native `skill` tool events. + * + * Rendered when the agent invokes the first-class `skill` tool to load a + * SKILL.md body. Shows the skill name as a subtitle so the user can see at + * a glance which skill was loaded without exposing the entire SKILL.md in + * the chat stream (which is large and model-internal). + * + * Routed via FallbackAdapter's isSkillTool() branch. Not a full ChatBlock + * variant — no Rust-side change needed. + */ +import React from "react"; +import { useTranslation } from "react-i18next"; + +import { TOOL_NAMES } from "@src/api/tauri/agent/toolNames"; +import { getEventIcon } from "@src/config/toolIcons"; +import type { ToolUsageMetadata } from "@src/engines/SessionCore/core/types"; + +import ToolUsageBadge from "../ToolCallBlock/ToolUsageBadge"; +import { + EventBlockHeader, + EventBlockHeaderIcon, + EventBlockHeaderSubtitle, + EventBlockHeaderTitle, + getEventBlockContainerClasses, +} from "../primitives"; +import { useBlockHeader } from "../useBlockLocate"; + +export interface SkillBlockProps { + /** Skill name from `args.skill`. */ + skillName?: string; + isLoading?: boolean; + isFailed?: boolean; + eventId?: string; + toolUsage?: ToolUsageMetadata; +} + +const SKILL_ICON = getEventIcon(TOOL_NAMES.SKILL); + +const SkillBlock: React.FC = React.memo( + ({ skillName, isLoading = false, isFailed = false, eventId, toolUsage }) => { + const { t } = useTranslation("sessions"); + const { + isHeaderHovered, + handleHeaderMouseEnter, + handleHeaderMouseLeave, + handleLocate, + } = useBlockHeader({ eventId }); + + const title = isLoading + ? t("tools.readFileSkillRunning") + : isFailed + ? t("tools.readFileSkillFailed") + : t("tools.readFileSkillDone"); + + return ( +
+ : undefined + } + > + + + {title} + + {skillName && ( + + {skillName} + + )} + +
+ ); + } +); + +SkillBlock.displayName = "SkillBlock"; + +export default SkillBlock; diff --git a/src/engines/ChatPanel/blocks/SubagentBlock/SubagentHelpers.tsx b/src/engines/ChatPanel/blocks/SubagentBlock/SubagentHelpers.tsx index f64955a34..e08aab458 100644 --- a/src/engines/ChatPanel/blocks/SubagentBlock/SubagentHelpers.tsx +++ b/src/engines/ChatPanel/blocks/SubagentBlock/SubagentHelpers.tsx @@ -4,7 +4,7 @@ import React, { memo, useCallback, useMemo, useState } from "react"; import ExpandOverlay from "@src/components/ExpandOverlay"; -import UserMessageContent from "@src/engines/ChatPanel/ChatHistory/components/UserMessageContent"; +import Markdown from "@src/components/MarkDown"; import { EVENT_BLOCK_FADE_FROM } from "../primitives"; @@ -91,7 +91,16 @@ export const SubagentPromptPreview: React.FC<{ role={!isExpanded && needsExpand ? "button" : undefined} tabIndex={!isExpanded && needsExpand ? 0 : undefined} > - +
+
+ +
+
{needsExpand && ( ); }); +export const SubagentResultPreview: React.FC<{ + content: string; +}> = memo(({ content }) => ( +
+
+ +
+
+)); +SubagentResultPreview.displayName = "SubagentResultPreview"; + SubagentPromptPreview.displayName = "SubagentPromptPreview"; diff --git a/src/engines/ChatPanel/blocks/SubagentBlock/index.tsx b/src/engines/ChatPanel/blocks/SubagentBlock/index.tsx index 537fe54c8..22c2de071 100644 --- a/src/engines/ChatPanel/blocks/SubagentBlock/index.tsx +++ b/src/engines/ChatPanel/blocks/SubagentBlock/index.tsx @@ -9,15 +9,17 @@ * * Visual states: * 1. **Running** — infinity icon, shimmer title, Stop button visible. - * 2. **Success** — infinity icon, prompt preview or summary body. + * 2. **Success** — infinity icon, assignment prompt preview when available. * 3. **Failed / cancelled** — infinity icon, error body. */ import { Infinity, Square } from "lucide-react"; -import React, { memo, useCallback, useEffect, useMemo, useState } from "react"; +import React, { memo, useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; +import type { ToolUsageMetadata } from "@src/engines/SessionCore/core/types"; import { createLogger } from "@src/hooks/logger"; +import ToolUsageBadge from "../ToolCallBlock/ToolUsageBadge"; import { EVENT_BLOCK_ICON_WRAPPER_CLASSES, EVENT_LOADING_SHIMMER_TEXT_CLASSES, @@ -25,11 +27,7 @@ import { SESSION_UI_TOKENS, getEventBlockContainerClasses, } from "../primitives"; -import { - SubagentPromptPreview, - extractSummary, - formatElapsedTime, -} from "./SubagentHelpers"; +import { SubagentPromptPreview, formatElapsedTime } from "./SubagentHelpers"; const log = createLogger("SubagentBlock"); @@ -54,6 +52,7 @@ export interface SubagentBlockProps { /** Called when the user clicks the navigate icon — locates the subagent * cell in the right-side monitor panel. */ onNavigate?: () => void; + toolUsage?: ToolUsageMetadata; } // ============================================ @@ -63,8 +62,6 @@ export interface SubagentBlockProps { const SubagentBlock: React.FC = memo( ({ description, - resultContent, - resultSummary, isLoading = false, elapsedMs, subagentSessionId, @@ -73,6 +70,7 @@ const SubagentBlock: React.FC = memo( success, errorMessage, onNavigate, + toolUsage, }) => { const { t } = useTranslation("sessions"); const { t: tCommon } = useTranslation(); @@ -94,11 +92,6 @@ const SubagentBlock: React.FC = memo( status === "cancelled" || (success === false && hasErrorMessage); - const summary = useMemo( - () => resultSummary || extractSummary(resultContent || ""), - [resultSummary, resultContent] - ); - const timingLabel = elapsedMs ? formatElapsedTime(elapsedMs) : undefined; // ── Stop button ── @@ -137,35 +130,33 @@ const SubagentBlock: React.FC = memo( if (timingLabel && !isLoading) subtitleParts.push(timingLabel); const subtitle = subtitleParts.join(" · "); - // ── Header right: stop button ── - const headerRight = ( -
- {canStop && ( - - )} -
- ); + const headerRight = + toolUsage || canStop ? ( +
+ {toolUsage && } + {canStop && ( + + )} +
+ ) : undefined; const displayTitle = t("tools.assignedTaskToSubagent"); const hasBody = - hasPrompt || - (isFailure && hasErrorMessage) || - (!isFailure && !isLoading && Boolean(summary)) || - (isLoading && !hasPrompt); + hasPrompt || (isFailure && hasErrorMessage) || (isLoading && !hasPrompt); return (
@@ -213,14 +204,6 @@ const SubagentBlock: React.FC = memo(
)} - {!hasPrompt && !isFailure && !isLoading && summary && ( -
- {summary} -
- )} - {isLoading && !hasPrompt && (
{ + it("keeps short commands unchanged", () => { + expect(truncateCommandPreview("npm test")).toBe("npm test"); + }); + + it("truncates after four lines", () => { + expect(truncateCommandPreview("one\ntwo\nthree\nfour\nfive")).toBe( + "one\ntwo\nthree\nfour..." + ); + }); + + it("truncates at 200 characters when that limit comes first", () => { + const command = "x".repeat(201); + expect(truncateCommandPreview(command)).toBe(`${"x".repeat(200)}...`); + }); +}); describe("getCommandSymbolList", () => { it("returns the executable of a simple command", () => { diff --git a/src/engines/ChatPanel/blocks/TerminalBlock/commandParser.ts b/src/engines/ChatPanel/blocks/TerminalBlock/commandParser.ts index ef519547f..b05e18171 100644 --- a/src/engines/ChatPanel/blocks/TerminalBlock/commandParser.ts +++ b/src/engines/ChatPanel/blocks/TerminalBlock/commandParser.ts @@ -7,6 +7,8 @@ * - `formatCommandForDisplay` — light pretty-printer that adds a newline * before each top-level shell operator so long compound commands wrap * at logical boundaries when rendered in the terminal body. + * - `truncateCommandPreview` — caps the rendered command at the earlier of + * a line limit or character limit and marks truncated content with `...`. * * - `getCommandSymbolList` — extracts the executable being invoked by * each sub-command, skipping prose inside quoted strings and heredoc @@ -19,6 +21,20 @@ export function formatCommandForDisplay(raw: string): string { return raw.replace(/ (&&|\|\||(? maxLines || lineLimited.length > maxChars; + + return wasTruncated ? `${preview.trimEnd()}...` : preview; +} + /** Strip outer punctuation/quotes from a bare token so `(npm)` / `./npm` / `` `git` `` all reduce to the executable basename. */ function cleanExecutableToken(token: string): string { let cleaned = token.replace(/^[`"']|[`"']$/g, ""); @@ -146,6 +162,18 @@ function extractSubCommandExecutables(commandText: string): string[] { continue; } if (ch === "&") { + // A bare `&` backgrounds a job, but `&` also appears inside + // redirections (`2>&1`, `>&2`, `&>file`, `&>>file`) where it must + // NOT start a new sub-command — otherwise the digit after it (e.g. + // the `1` in `2>&1`) is mis-captured as an executable. + const prev = line[i - 1]; + const next = line[i + 1]; + const isRedirection = prev === ">" || next === ">"; + if (isRedirection) { + if (expectingExecutable) currentToken += ch; + sawNonWhitespace = true; + continue; + } startNewSubCommand(); continue; } diff --git a/src/engines/ChatPanel/blocks/TerminalBlock/index.tsx b/src/engines/ChatPanel/blocks/TerminalBlock/index.tsx index dc1c5d467..916d955ec 100644 --- a/src/engines/ChatPanel/blocks/TerminalBlock/index.tsx +++ b/src/engines/ChatPanel/blocks/TerminalBlock/index.tsx @@ -1,9 +1,9 @@ /** * TerminalBlock Component * - * Same outer shell as ChatCodeBlock (edit): one `bg-fill-2` rounded card; header and - * body share that background. Command + output sit below the header without a nested - * second fill panel. + * Transparent event header matching Explore/LSP blocks. Expanded command and + * output content lives in the shared filled body shell, separated by a subtle + * divider without additional section labels. */ import { Square } from "lucide-react"; import React, { @@ -16,25 +16,32 @@ import React, { } from "react"; import { useTranslation } from "react-i18next"; -import ExpandOverlay from "@src/components/ExpandOverlay"; +import "@src/components/TerminalDisplay/index.scss"; import { getToolIcon } from "@src/config/toolIcons"; -import type { PayloadRef } from "@src/engines/SessionCore/core/types"; +import type { + PayloadRef, + ToolUsageMetadata, +} from "@src/engines/SessionCore/core/types"; +import ToolUsageBadge from "../ToolCallBlock/ToolUsageBadge"; import { BlockOutput, - EVENT_BLOCK_FADE_FROM, + EVENT_BLOCK_TRANSPARENT_EXPANDED_SHELL_CLASSES, EVENT_LOADING_SHIMMER_TEXT_CLASSES, EventBlockHeader, EventBlockHeaderIcon, + EventBlockHeaderSubtitle, EventBlockHeaderTitle, getEventBlockContainerClasses, } from "../primitives"; import { useBlockHeader } from "../useBlockLocate"; -import { formatCommandForDisplay, getCommandSymbolList } from "./commandParser"; +import { + formatCommandForDisplay, + getCommandSymbolList, + truncateCommandPreview, +} from "./commandParser"; -const TERMINAL_COMMAND_PREVIEW_MAX_HEIGHT = 72; const TERMINAL_OUTPUT_PREVIEW_MAX_HEIGHT = 72; -const TERMINAL_EXPANDED_MAX_HEIGHT = "min(320px, 30vh)"; const TERMINAL_OUTPUT_EXPAND_LINE_THRESHOLD = 3; export interface TerminalBlockProps { @@ -45,6 +52,8 @@ export interface TerminalBlockProps { isError?: boolean; defaultCollapsed?: boolean; title?: string; + /** Optional secondary detail shown after the title, truncated to preserve space for command symbols. */ + subtitle?: string; headerIcon?: React.ReactNode; runningStatusText?: string; runningStatusIcon?: React.ReactNode; @@ -66,6 +75,10 @@ export interface TerminalBlockProps { processStatus?: "running" | "background" | "exited" | "killed"; /** Callback when user clicks Stop */ onStop?: (pid: number) => void; + /** Token/context attribution metadata for this shell call. */ + toolUsage?: ToolUsageMetadata; + /** When true, renders output through xterm.js instead of ansi-to-react. */ + tuiRendering?: boolean; } const TerminalBlock: React.FC = memo( @@ -77,6 +90,7 @@ const TerminalBlock: React.FC = memo( isError = false, defaultCollapsed, title, + subtitle, headerIcon, runningStatusText, runningStatusIcon, @@ -88,6 +102,8 @@ const TerminalBlock: React.FC = memo( pid, processStatus, onStop, + toolUsage, + tuiRendering, }) => { const isErrorExit = exitCode !== undefined && exitCode !== 0; const isBackground = processStatus === "background"; @@ -126,7 +142,14 @@ const TerminalBlock: React.FC = memo( const { t } = useTranslation("sessions"); const { t: tCommon } = useTranslation(); const displayOutput = output || streamOutput; + // When the agent provides a description (human summary), promote it to the + // primary title and drop the default lifecycle label. The parsed command + // symbols (git, npm, …) still render separately, so the command stays + // visible in the header. + const trimmedSubtitle = subtitle?.trim(); + const hasDescriptionTitle = Boolean(trimmedSubtitle); const displayTitle = + trimmedSubtitle || title?.trim() || (isLoading ? t("tools.runCommandRunning") : t("tools.runCommandDone")); const commandSymbols = useMemo( @@ -139,26 +162,10 @@ const TerminalBlock: React.FC = memo( () => (command ? formatCommandForDisplay(command) : ""), [command] ); - const commandViewportRef = useRef(null); - const [isCommandExpanded, setIsCommandExpanded] = useState(false); - const [commandNeedsExpand, setCommandNeedsExpand] = useState(false); - - useEffect(() => { - const element = commandViewportRef.current; - if (!element || !command) return; - - const measure = () => { - setCommandNeedsExpand(element.scrollHeight > element.clientHeight + 1); - }; - - const frameId = requestAnimationFrame(measure); - const observer = new ResizeObserver(measure); - observer.observe(element); - return () => { - cancelAnimationFrame(frameId); - observer.disconnect(); - }; - }, [command, formattedCommand, isCommandExpanded]); + const commandPreview = useMemo( + () => truncateCommandPreview(formattedCommand), + [formattedCommand] + ); // Stop button state — reset when process finishes. // @@ -205,8 +212,9 @@ const TerminalBlock: React.FC = memo( const hasContent = Boolean(command || displayOutput); const headerRight = - statusLabel || canStop ? ( + toolUsage || statusLabel || canStop ? (
+ {toolUsage && } {statusLabel} {canStop && ( - ); -}); -StepCard.displayName = "StepCard"; - -// ============================================ -// Chat Variant -// ============================================ - -const ChatVariant: React.FC<{ - steps: StepProposal[]; - isLoading: boolean; - isFailed: boolean; - eventId?: string; -}> = ({ steps, isLoading, isFailed, eventId }) => { - const labels = useLifecycleLabels("suggest_next_steps", undefined, { - count: steps.length, - }); - - const sessionId = useAtomValue(activeSessionIdAtom); - const isSessionActive = useAtomValue(isSessionActiveAtom); - const events = useAtomValue(eventsAtom); - - const { addUserMessage, dispatchMessageBySessionType } = useMessageDispatch({ - getSessionId: () => sessionId, - }); - - const [selectedIdx, setSelectedIdx] = useState(null); - const pendingRef = useRef(false); - - const toolIcon = getToolIcon("suggest_next_steps", { - size: 14, - className: "text-text-2", - }); - - // Cards stay interactive as long as the user has not sent a new message - // after this event. Turn summary replay events (displayVariant "summary") - // contain the original user prompt but are NOT new user input — skip them. - // If the event ID is missing or not found (e.g. historical sessions with - // reloaded event IDs), default to allowing interaction. - const isLastEvent = useMemo(() => { - if (!eventId) return true; - let foundThisEvent = false; - for (let idx = 0; idx < events.length; idx++) { - const evt = events[idx]; - if (evt.id === eventId) { - foundThisEvent = true; - continue; - } - if ( - foundThisEvent && - evt.source === "user" && - evt.displayVariant !== "summary" - ) { - return false; - } - } - return true; - }, [events, eventId]); - - // Preview mode: no real agent session is active (DevTools playground, - // storybook-style previews, etc.). Render cards as enabled-looking but - // clicks are no-ops so the preview doesn't look broken/disabled. - const isPreviewMode = sessionId == null; - - const canInteract = - isPreviewMode || (isLastEvent && !isSessionActive && selectedIdx === null); - - const handleSelect = useCallback( - async (idx: number) => { - if (!canInteract || pendingRef.current) return; - const step = steps[idx]; - if (!step) return; - if (isPreviewMode || sessionId == null) return; - - pendingRef.current = true; - setSelectedIdx(idx); - - beginOptimisticTurn(sessionId, "interactive-event"); - - void (async () => { - try { - // Mint one canonical user-intent id and use it for both the - // optimistic synthetic event and the wire dispatch so the turn - // indexer can collapse the two rows under a single round. - const turnIntentId = mintTurnIntentId(); - await addUserMessage(step.command, undefined, turnIntentId); - await dispatchMessageBySessionType( - sessionId, - step.command, - undefined, - undefined, - undefined, - undefined, - turnIntentId - ); - } catch (err) { - log.error("[NextStepEvent] send failed:", err); - setSelectedIdx(null); - } finally { - pendingRef.current = false; - } - })(); - }, - [ - canInteract, - isPreviewMode, - steps, - sessionId, - addUserMessage, - dispatchMessageBySessionType, - ] - ); - - if (isLoading) { - return ( - - - - {labels.running} - - - ); - } - - if (isFailed) return null; - - if (steps.length === 0) return null; - - return ( -
- {/* Header row */} -
- - - {labels.done} - -
- - {/* Step cards */} -
- {steps.map((step, idx) => ( - handleSelect(idx)} - index={idx} - /> - ))} -
-
- ); -}; - -// ============================================ -// Main Component -// ============================================ - -export const NextStepEvent: React.FC = (props) => { - const normalizedProps = useNormalizedEventProps(props, "suggest_next_steps"); - - if (!normalizedProps) return null; - - const isLoading = - normalizedProps.status === "running" && - normalizedProps.showActiveEventPainting === true; - const isFailed = normalizedProps.status === "failed"; - const steps = extractSteps(normalizedProps.result ?? {}); - - return ( - - ); -}; - -NextStepEvent.displayName = "NextStepEvent"; - -export default NextStepEvent; diff --git a/src/engines/ChatPanel/events/stream/agent-message/index.tsx b/src/engines/ChatPanel/events/stream/agent-message/index.tsx index 32eb7096a..cbddf5781 100644 --- a/src/engines/ChatPanel/events/stream/agent-message/index.tsx +++ b/src/engines/ChatPanel/events/stream/agent-message/index.tsx @@ -19,8 +19,9 @@ import { getEventIcon } from "@src/config/toolIcons"; import AgentChatItemDefault from "@src/engines/ChatPanel/ChatItems/AgentChatItemDefault"; import { AgentMessageBlock } from "@src/engines/ChatPanel/blocks"; import CanvasInlineCard from "@src/engines/ChatPanel/blocks/CanvasInlineCard"; -import { useCanvasPreviewForSession } from "@src/engines/ChatPanel/blocks/CanvasInlineCard/useCanvasPreviewForSession"; +import { useCanvasForTurn } from "@src/engines/ChatPanel/blocks/CanvasInlineCard/useCanvasForTurn"; import MessageReferenceCards from "@src/engines/ChatPanel/blocks/MessageReferenceCards"; +import LlmUsageBadge from "@src/engines/ChatPanel/blocks/ToolCallBlock/LlmUsageBadge"; import { SessionLinkCard, type SessionLinkCardData, @@ -39,7 +40,10 @@ import { type RawEventInput, useNormalizedEventProps, } from "@src/engines/SessionCore/rendering/props"; -import type { EventVariant } from "@src/engines/SessionCore/rendering/types/universalProps"; +import type { + EventVariant, + UniversalEventProps, +} from "@src/engines/SessionCore/rendering/types/universalProps"; import { extractThinkContent, stripThinkTags, @@ -152,8 +156,10 @@ interface ChatVariantProps { isStreaming?: boolean; sessionId?: string | null; canvasUrls?: ReadonlySet; + llmUsage?: UniversalEventProps["llmUsage"]; /** Event id used by AgentMessageBlock's locate-in-simulator arrow. */ eventId?: string; + timestamp?: string; } const ChatVariant: React.FC = ({ @@ -163,10 +169,21 @@ const ChatVariant: React.FC = ({ isStreaming = false, sessionId, canvasUrls, + llmUsage, eventId, + timestamp, }) => { - const { payload: canvasPayload, dismiss: _dismissCanvas } = - useCanvasPreviewForSession(sessionId); + // Canvas preview from the global atom is only relevant for the live + // streaming message. Historical (non-streaming) messages already have + // their canvas rendered inline via CanvasInlineAdapter in the event list. + // Reading the global atom unconditionally caused re-shows: any time a new + // round started and openInSimulatorCanvas cleared cardDismissed, every + // historical ChatVariant instance would briefly re-render the old canvas. + const { snapshot: streamingCanvas } = useCanvasForTurn( + isStreaming ? sessionId : null + ); + const streamingCanvasPayload = streamingCanvas.payload; + const canvasPayload = isStreaming ? streamingCanvasPayload : null; if (!content && !thinkingContent && !isStreaming && !canvasPayload) return null; @@ -182,12 +199,22 @@ const ChatVariant: React.FC = ({ <> {thinkingContent && } {hasVisibleContent && ( - + : undefined + } + > = (props) => { const normalizedProps = useNormalizedEventProps(props, "agent_message"); const sessionId = useAtomValue(sessionIdAtom); const streamingMap = useAtomValue(streamingDeltaContentAtom); - const directStreamContent = sessionId - ? (streamingMap.get(sessionId) ?? null) - : null; + const liveDelta = sessionId ? (streamingMap.get(sessionId) ?? null) : null; + const directStreamContent = + liveDelta?.kind === "message" ? liveDelta.content : null; const isSyntheticLiveEvent = props.event?.args?.syntheticLive === true || @@ -389,7 +416,9 @@ export const AgentMessageEvent: React.FC = (props) => { isStreaming={props.isStreaming} sessionId={sessionId} canvasUrls={canvasUrls} + llmUsage={normalizedProps?.llmUsage} eventId={normalizedProps?.eventId} + timestamp={normalizedProps?.timestamp ?? props.event?.createdAt} /> ); } diff --git a/src/engines/ChatPanel/events/stream/context-compacted/index.tsx b/src/engines/ChatPanel/events/stream/context-compacted/index.tsx new file mode 100644 index 000000000..42f4cd58f --- /dev/null +++ b/src/engines/ChatPanel/events/stream/context-compacted/index.tsx @@ -0,0 +1,127 @@ +/** + * ContextCompactedEvent — Collapsed compact-boundary marker. + * + * Rendered via the event registry under `context_compacted` for persisted + * compact-boundary rows (see `persistedMessageToSessionEvent` in + * SessionCore/ingestion/agentMessageAdapters.ts). Shows a collapsed-by-default + * block titled "Context compacted" with the cleaned conversation summary in + * the expanded body; the model-facing continuation instructions are stripped + * at ingestion. + * + * Boundary rows only arrive via the history reload path (never live events), + * so this component renders the same block for every variant — mirroring + * RateLimitHintEvent rather than ThinkingEvent's chat/simulator split. + */ +import { Archive } from "lucide-react"; +import React from "react"; +import { useTranslation } from "react-i18next"; + +import Markdown from "@src/components/MarkDown"; +import { formatTokenCount } from "@src/engines/ChatPanel/InputArea/components/useContextUsageInfo"; +import { + EventBlockHeader, + EventBlockHeaderIcon, + EventBlockHeaderTitle, + SESSION_UI_TOKENS, + getEventBlockContainerClasses, + getEventBlockContentClasses, + useEventBlockHeader, +} from "@src/engines/ChatPanel/blocks/primitives"; +import { + type RawEventInput, + useNormalizedEventProps, +} from "@src/engines/SessionCore/rendering/props"; +import type { EventVariant } from "@src/engines/SessionCore/rendering/types/universalProps"; + +export interface ContextCompactedEventProps extends RawEventInput { + /** Force a specific variant (auto-detected if not provided) */ + variant?: EventVariant; +} + +export const ContextCompactedEvent: React.FC = ( + props +) => { + const { t } = useTranslation(); + const normalizedProps = useNormalizedEventProps(props, "context_compacted"); + const { + isCollapsed, + isHeaderHovered, + handleHeaderClick, + handleHeaderMouseEnter, + handleHeaderMouseLeave, + } = useEventBlockHeader({ + defaultCollapsed: true, + collapseAllValue: true, + }); + + if (!normalizedProps) return null; + + const result = normalizedProps.result ?? {}; + const summary = + typeof result.observation === "string" ? result.observation : ""; + const compactedCount = + typeof result.compactedCount === "number" ? result.compactedCount : null; + const tokensBefore = + typeof result.tokensBefore === "number" ? result.tokensBefore : null; + const tokensAfter = + typeof result.tokensAfter === "number" ? result.tokensAfter : null; + const hasContent = Boolean(summary.trim()); + + const icon = ( + + ); + + return ( +
+ + + + {t("contextInfo.compactBoundaryTitle")} + + {compactedCount !== null && ( + + {t("contextInfo.compactBoundarySubtitle", { + count: compactedCount, + })} + + )} + {tokensBefore !== null && tokensAfter !== null && ( + + {t("contextInfo.compactBoundaryTokens", { + before: formatTokenCount(tokensBefore), + after: formatTokenCount(tokensAfter), + })} + + )} + + + {!isCollapsed && hasContent && ( +
+
+
+ +
+
+
+ )} +
+ ); +}; + +ContextCompactedEvent.displayName = "ContextCompactedEvent"; + +export default ContextCompactedEvent; diff --git a/src/engines/ChatPanel/events/stream/thinking/index.tsx b/src/engines/ChatPanel/events/stream/thinking/index.tsx index abc6a84a7..5ebda0abf 100644 --- a/src/engines/ChatPanel/events/stream/thinking/index.tsx +++ b/src/engines/ChatPanel/events/stream/thinking/index.tsx @@ -23,9 +23,11 @@ import { useTranslation } from "react-i18next"; import Markdown from "@src/components/MarkDown"; import { getEventIcon } from "@src/config/toolIcons"; import { hasThinkingEventType } from "@src/engines/ChatPanel/ChatHistory/chatItemPipeline/filters"; +import LlmUsageBadge from "@src/engines/ChatPanel/blocks/ToolCallBlock/LlmUsageBadge"; import { EventBlockHeader, EventBlockHeaderIcon, + EventBlockHeaderSubtitle, EventBlockHeaderTitle, getEventBlockContainerClasses, getEventBlockContentClasses, @@ -37,7 +39,13 @@ import { extractThinkingData, useNormalizedEventProps, } from "@src/engines/SessionCore/rendering/props"; -import type { EventVariant } from "@src/engines/SessionCore/rendering/types/universalProps"; +import type { + EventVariant, + UniversalEventProps, +} from "@src/engines/SessionCore/rendering/types/universalProps"; +import { formatDuration } from "@src/util/time/formatDuration"; + +import { getThoughtPreview } from "./thoughtPreview"; const LazySimulatorMessages = lazy( () => import("@src/modules/WorkStation/Chat/Communication") @@ -56,18 +64,57 @@ export interface ThinkingEventProps extends RawEventInput { // Chat Variant (uses ThinkingBlock styling) // ============================================ +interface ThoughtSubtitleProps { + content?: string; + duration?: number; + isLoading: boolean; +} + +const ThoughtSubtitle: React.FC = ({ + content, + duration, + isLoading, +}) => { + const preview = getThoughtPreview(content); + const durationLabel = duration ? formatDuration(duration) : null; + + if (!preview && !durationLabel) return null; + + return ( + + {durationLabel && ( + + {durationLabel} + + )} + {durationLabel && preview && ( + · + )} + {preview && {preview}} + + ); +}; + interface ChatVariantProps { content?: string; + duration?: number; isLoading: boolean; isStreaming?: boolean; eventId?: string; + llmUsage?: UniversalEventProps["llmUsage"]; } const ChatVariant: React.FC = ({ content, + duration, isLoading, isStreaming = false, eventId, + llmUsage, }) => { const { t } = useTranslation("sessions"); const { @@ -100,6 +147,7 @@ const ChatVariant: React.FC = ({ onNavigate={eventId ? handleLocate : undefined} onMouseEnter={handleHeaderMouseEnter} onMouseLeave={handleHeaderMouseLeave} + rightContent={llmUsage ? : undefined} > = ({ {title} + {!isCollapsed && ( @@ -151,7 +204,7 @@ export const ThinkingEvent: React.FC = (props) => { if (!normalizedProps) return null; - const { content } = extractThinkingData(normalizedProps); + const { content, duration } = extractThinkingData(normalizedProps); const displayContent = props.streamingContent || content; const hasContent = Boolean(displayContent?.trim()); const isThinkingEvent = props.event @@ -165,9 +218,11 @@ export const ThinkingEvent: React.FC = (props) => { return ( ); } diff --git a/src/engines/ChatPanel/events/stream/thinking/thoughtPreview.test.ts b/src/engines/ChatPanel/events/stream/thinking/thoughtPreview.test.ts new file mode 100644 index 000000000..7633ebf84 --- /dev/null +++ b/src/engines/ChatPanel/events/stream/thinking/thoughtPreview.test.ts @@ -0,0 +1,39 @@ +import { + getThoughtPreview, + stripMarkdownForThoughtPreview, +} from "./thoughtPreview"; + +describe("stripMarkdownForThoughtPreview", () => { + it("removes emphasis markers from thinking subtitles", () => { + expect( + stripMarkdownForThoughtPreview("**Grouping asset and icon changes**") + ).toBe("Grouping asset and icon changes"); + }); + + it("keeps readable content while removing common Markdown syntax", () => { + const markdown = [ + "## Reviewing `Button.tsx`", + "> Compare [the shared component](https://example.com) with:", + "- **bold**, _italic_, and ~~old~~ styles", + ].join("\n"); + + expect(stripMarkdownForThoughtPreview(markdown)).toBe( + "Reviewing Button.tsx Compare the shared component with: bold, italic, and old styles" + ); + }); + + it("does not remove underscores from plain identifiers", () => { + expect( + stripMarkdownForThoughtPreview("Checking user_profile_id next") + ).toBe("Checking user_profile_id next"); + }); +}); + +describe("getThoughtPreview", () => { + it("truncates the plain-text result rather than leaving partial markup", () => { + const preview = getThoughtPreview(`**${"a".repeat(120)}**`); + + expect(preview).toBe(`${"a".repeat(96)}...`); + expect(preview).not.toContain("*"); + }); +}); diff --git a/src/engines/ChatPanel/events/stream/thinking/thoughtPreview.ts b/src/engines/ChatPanel/events/stream/thinking/thoughtPreview.ts new file mode 100644 index 000000000..d63a286e9 --- /dev/null +++ b/src/engines/ChatPanel/events/stream/thinking/thoughtPreview.ts @@ -0,0 +1,33 @@ +const THOUGHT_PREVIEW_MAX_LENGTH = 96; + +/** Convert Markdown reasoning into compact plain text for the header subtitle. */ +export function stripMarkdownForThoughtPreview(content: string): string { + return content + .replace(/^\s*```[^\n]*$/gm, "") + .replace(/!\[([^\]]*)\]\((?:\\.|[^)])*\)/g, "$1") + .replace(/\[([^\]]+)\]\((?:\\.|[^)])*\)/g, "$1") + .replace(/\[([^\]]+)\]\[[^\]]*\]/g, "$1") + .replace(/<((?:https?:\/\/|mailto:)[^>]+)>/g, "$1") + .replace(/<[^>]+>/g, "") + .replace(/^\s{0,3}#{1,6}[\t ]+/gm, "") + .replace(/^\s{0,3}>[\t ]?/gm, "") + .replace(/^\s{0,3}(?:[-+*]|\d+[.)])[\t ]+/gm, "") + .replace(/^\s*\[[ xX]\][\t ]+/gm, "") + .replace(/(`+)([\s\S]*?)\1/g, "$2") + .replace(/~~(?=\S)([\s\S]*?\S)~~/g, "$1") + .replace(/(\*\*|__)(?=\S)([\s\S]*?\S)\1/g, "$2") + .replace(/\*(?=\S)([^*\n]*?\S)\*/g, "$1") + .replace(/(^|[^\w])_(?=\S)([^_\n]*?\S)_(?!\w)/gm, "$1$2") + .replace(/\\([\\`*_[\]{}()#+.!>|~-])/g, "$1") + .replace(/\s+/g, " ") + .trim(); +} + +export function getThoughtPreview(content?: string): string | null { + if (!content) return null; + + const plainText = stripMarkdownForThoughtPreview(content); + if (!plainText) return null; + if (plainText.length <= THOUGHT_PREVIEW_MAX_LENGTH) return plainText; + return `${plainText.slice(0, THOUGHT_PREVIEW_MAX_LENGTH).trimEnd()}...`; +} diff --git a/src/engines/ChatPanel/externalHistoryFork.test.ts b/src/engines/ChatPanel/externalHistoryFork.test.ts new file mode 100644 index 000000000..7cbd0df24 --- /dev/null +++ b/src/engines/ChatPanel/externalHistoryFork.test.ts @@ -0,0 +1,182 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + type ImportedHistorySource, + getImportedHistorySourceBySessionId, +} from "@src/api/tauri/externalHistory"; +import { SessionService } from "@src/engines/SessionCore/services/SessionService"; +import { requestForkSessionSetup } from "@src/features/TeamCollaboration/forkSession"; +import { resolveShareableScopeKeys } from "@src/features/TeamCollaboration/repoScopeResolver"; +import type { ActivityChunk } from "@src/types/session/session"; + +import { + buildExternalHistoryHandoffPrompt, + forkExternalHistoryIntoOrgiiSession, +} from "./externalHistoryFork"; + +vi.mock("@src/api/tauri/externalHistory", () => ({ + getImportedHistorySourceBySessionId: vi.fn(), +})); +vi.mock("@src/engines/SessionCore/services/SessionService", () => ({ + SessionService: { create: vi.fn() }, +})); +vi.mock("@src/features/TeamCollaboration/forkSession", () => ({ + requestForkSessionSetup: vi.fn(), +})); +vi.mock("@src/features/TeamCollaboration/repoScopeResolver", () => ({ + resolveShareableScopeKeys: vi.fn(), +})); + +function chunk( + id: string, + actionType: string, + functionName: string, + result: Record +): ActivityChunk { + return { + chunk_id: id, + action_type: actionType, + function: functionName, + args: {}, + result, + created_at: "2026-07-13T00:00:00.000Z", + }; +} + +describe("buildExternalHistoryHandoffPrompt", () => { + it("works for every registered source label and excludes private reasoning", () => { + const prompt = buildExternalHistoryHandoffPrompt( + [ + chunk("u1", "raw", "user_message", { message: "fix the sync" }), + chunk("r1", "reasoning", "thinking", { + content: "private chain of thought", + }), + { + ...chunk("t1", "tool_call", "read_file", { output: "old file" }), + args: { path: "src/sync.ts" }, + }, + chunk("a1", "assistant_message", "assistant_message", { + content: "I found the issue", + }), + ], + "continue and verify it", + "Claude App" + ); + + expect(prompt).toContain("imported Claude App history"); + expect(prompt).toContain("User: fix the sync"); + expect(prompt).toContain("[Imported Claude App action]"); + expect(prompt).toContain("Tool: read_file"); + expect(prompt).toContain("Assistant: I found the issue"); + expect(prompt).toContain("continue and verify it"); + expect(prompt).not.toContain("private chain of thought"); + }); +}); + +describe("forkExternalHistoryIntoOrgiiSession", () => { + const loadFullTranscriptChunks = vi.fn(); + const source: ImportedHistorySource = { + sourceId: "codex_app", + listCategory: "external_history:codex_app", + prefix: "codexapp-", + iconId: "codex", + displayName: "Codex App", + groupLabel: "Codex App", + listable: true, + replayable: true, + supportsWindowedReplay: false, + dispatchCategory: "external_history", + loadPreviewChunks: vi.fn(), + loadFullTranscriptChunks, + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getImportedHistorySourceBySessionId).mockReturnValue(source); + vi.mocked(resolveShareableScopeKeys).mockResolvedValue([ + "github.com/org/repo", + ]); + vi.mocked(requestForkSessionSetup).mockResolvedValue({ + workspaceRepoPath: "/local/repo", + execution: { accountId: "openai", model: "gpt-test" }, + }); + loadFullTranscriptChunks.mockResolvedValue([ + chunk("u1", "user_message", "user_message", { message: "old ask" }), + ]); + vi.mocked(SessionService.create).mockResolvedValue({ + sessionId: "agentsession-forked", + }); + }); + + it("uses the shared setup before loading history, then creates one writable ORGII continuation", async () => { + const callOrder: string[] = []; + vi.mocked(requestForkSessionSetup).mockImplementation(async () => { + callOrder.push("setup"); + return { + workspaceRepoPath: "/local/repo", + execution: { accountId: "openai", model: "gpt-test" }, + }; + }); + loadFullTranscriptChunks.mockImplementation(async () => { + callOrder.push("transcript"); + return [ + chunk("u1", "user_message", "user_message", { + message: "old ask", + }), + ]; + }); + + const sessionId = await forkExternalHistoryIntoOrgiiSession({ + sourceSessionId: "codexapp-source-1", + sourceSession: { + session_id: "codexapp-source-1", + status: "completed", + created_at: "2026-07-13T00:00:00Z", + updated_at: "2026-07-13T00:00:00Z", + name: "Imported review", + repoPath: "/source/repo", + model: "gpt-source", + }, + userMessage: "continue and run tests", + imageDataUrls: ["data:image/png;base64,abc"], + }); + + expect(sessionId).toBe("agentsession-forked"); + expect(callOrder).toEqual(["setup", "transcript"]); + expect(resolveShareableScopeKeys).toHaveBeenCalledWith("/source/repo"); + expect(requestForkSessionSetup).toHaveBeenCalledWith({ + sourceTitle: "Imported review", + sourceScopeKey: "github.com/org/repo", + sourceModel: "gpt-source", + }); + expect(SessionService.create).toHaveBeenCalledTimes(1); + expect(SessionService.create).toHaveBeenCalledWith( + expect.objectContaining({ + imageDataUrls: ["data:image/png;base64,abc"], + name: "Continue Imported review", + repoPath: "/local/repo", + model: "gpt-test", + accountId: "openai", + keySource: "own_key", + mode: "build", + parentSessionId: "codexapp-source-1", + task: expect.stringContaining("continue and run tests"), + }) + ); + }); + + it("does not load or create anything when the shared setup is cancelled", async () => { + vi.mocked(requestForkSessionSetup).mockRejectedValueOnce( + new Error("cancelled") + ); + + await expect( + forkExternalHistoryIntoOrgiiSession({ + sourceSessionId: "codexapp-source-1", + userMessage: "continue", + }) + ).rejects.toThrow("cancelled"); + expect(loadFullTranscriptChunks).not.toHaveBeenCalled(); + expect(SessionService.create).not.toHaveBeenCalled(); + }); +}); diff --git a/src/engines/ChatPanel/externalHistoryFork.ts b/src/engines/ChatPanel/externalHistoryFork.ts new file mode 100644 index 000000000..bf7571919 --- /dev/null +++ b/src/engines/ChatPanel/externalHistoryFork.ts @@ -0,0 +1,154 @@ +import { getImportedHistorySourceBySessionId } from "@src/api/tauri/externalHistory"; +import { SessionService } from "@src/engines/SessionCore/services/SessionService"; +import { requestForkSessionSetup } from "@src/features/TeamCollaboration/forkSession"; +import { resolveShareableScopeKeys } from "@src/features/TeamCollaboration/repoScopeResolver"; +import type { Session } from "@src/store/session"; +import type { ActivityChunk } from "@src/types/session/session"; +import { BUILTIN_SDE_DEF_ID } from "@src/util/session/sessionDispatch"; + +const MAX_HISTORY_ITEMS = 80; +const MAX_TEXT_LENGTH = 1200; + +function textValue(value: unknown): string | undefined { + if (typeof value === "string") { + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; + } + if (Array.isArray(value)) { + const parts = value.map(textValue).filter(Boolean); + return parts.length > 0 ? parts.join("\n") : undefined; + } + if (value && typeof value === "object") { + const object = value as Record; + return ( + textValue(object.text) ?? + textValue(object.content) ?? + textValue(object.message) ?? + textValue(object.output) ?? + textValue(object.summary) + ); + } + return undefined; +} + +function truncateText(text: string): string { + return text.length > MAX_TEXT_LENGTH + ? `${text.slice(0, MAX_TEXT_LENGTH)}…` + : text; +} + +function summarizeToolChunk( + chunk: ActivityChunk, + sourceName: string +): string | undefined { + const functionName = chunk.function || "unknown_tool"; + const argsText = textValue(chunk.args); + const resultText = textValue(chunk.result); + const lines = [`[Imported ${sourceName} action]`, `Tool: ${functionName}`]; + if (argsText) lines.push(`Input: ${truncateText(argsText)}`); + if (resultText) + lines.push(`Result at that time: ${truncateText(resultText)}`); + return lines.join("\n"); +} + +function chunkToHandoffItem( + chunk: ActivityChunk, + sourceName: string +): string | undefined { + const actionType = chunk.action_type; + if (actionType.includes("thinking") || actionType.includes("reasoning")) { + return undefined; + } + + const resultText = textValue(chunk.result); + const argsText = textValue(chunk.args); + const content = resultText ?? argsText; + + if (actionType === "user_message" || chunk.function === "user_message") { + return content ? `User: ${truncateText(content)}` : undefined; + } + if ( + actionType === "assistant_message" || + actionType === "llm_response" || + chunk.function === "assistant_message" + ) { + return content ? `Assistant: ${truncateText(content)}` : undefined; + } + if (actionType === "tool_call" || actionType.includes("tool")) { + return summarizeToolChunk(chunk, sourceName); + } + + return content ? `Assistant context: ${truncateText(content)}` : undefined; +} + +export function buildExternalHistoryHandoffPrompt( + chunks: ActivityChunk[], + userMessage: string, + sourceName: string +): string { + const items = chunks + .map((chunk) => chunkToHandoffItem(chunk, sourceName)) + .filter((item): item is string => Boolean(item)) + .slice(-MAX_HISTORY_ITEMS); + + return [ + `You are continuing work from an imported ${sourceName} history inside a new ORGII-owned session.`, + `The imported ${sourceName} history is read-only historical context. Do not treat its tool calls as ORGII-executed tools or current workspace state.`, + "Imported tool results may be stale; verify files, commands, and failures against the selected workspace before relying on them.", + "Reasoning/thinking chunks were intentionally skipped.", + "", + `## Imported ${sourceName} handoff context`, + items.length > 0 + ? items.join("\n\n") + : "No usable transcript items were found.", + "", + "## User request to continue in ORGII", + userMessage, + ].join("\n"); +} + +export async function forkExternalHistoryIntoOrgiiSession(params: { + sourceSessionId: string; + sourceSession?: Session; + userMessage: string; + imageDataUrls?: string[]; +}): Promise { + const source = getImportedHistorySourceBySessionId(params.sourceSessionId); + if (!source) { + throw new Error( + `No imported-history source is registered for ${params.sourceSessionId}` + ); + } + const sourceRepoPath = + params.sourceSession?.repoPath || params.sourceSession?.worktreePath; + const sourceScopeKeys = sourceRepoPath + ? await resolveShareableScopeKeys(sourceRepoPath) + : null; + // Prompt before loading the potentially large source transcript. The user + // chooses this machine's real checkout and credentials; an imported model + // label is only a preference hint, never an execution fallback. + const setup = await requestForkSessionSetup({ + sourceTitle: params.sourceSession?.name || `${source.displayName} history`, + sourceScopeKey: sourceScopeKeys?.[0], + sourceModel: params.sourceSession?.model, + }); + const chunks = await source.loadFullTranscriptChunks(params.sourceSessionId); + const content = buildExternalHistoryHandoffPrompt( + chunks, + params.userMessage, + source.displayName + ); + const result = await SessionService.create({ + task: content, + imageDataUrls: params.imageDataUrls, + name: `Continue ${params.sourceSession?.name || `${source.displayName} history`}`, + repoPath: setup.workspaceRepoPath ?? undefined, + model: setup.execution.model, + accountId: setup.execution.accountId, + keySource: "own_key", + agentDefinitionId: BUILTIN_SDE_DEF_ID, + mode: "build", + parentSessionId: params.sourceSessionId, + }); + return result.sessionId; +} diff --git a/src/engines/ChatPanel/header/chatPanelHeaderSlots.ts b/src/engines/ChatPanel/header/chatPanelHeaderSlots.ts index 53a7e580c..726ba67d6 100644 --- a/src/engines/ChatPanel/header/chatPanelHeaderSlots.ts +++ b/src/engines/ChatPanel/header/chatPanelHeaderSlots.ts @@ -5,6 +5,8 @@ export interface ChatPanelHeaderSlots { leading?: ReactNode; content?: ReactNode; trailing?: ReactNode; + backAction?: (() => void) | null; + backLabel?: string; } export type ChatPanelHeaderContribution = @@ -21,7 +23,9 @@ function isChatPanelHeaderSlots( !Array.isArray(contribution) && ("leading" in contribution || "content" in contribution || - "trailing" in contribution) + "trailing" in contribution || + "backAction" in contribution || + "backLabel" in contribution) ); } diff --git a/src/engines/ChatPanel/hooks/useAgentOrgGroupChatLiveSessions.tsx b/src/engines/ChatPanel/hooks/useAgentOrgGroupChatLiveSessions.tsx index 7f1785813..55692519e 100644 --- a/src/engines/ChatPanel/hooks/useAgentOrgGroupChatLiveSessions.tsx +++ b/src/engines/ChatPanel/hooks/useAgentOrgGroupChatLiveSessions.tsx @@ -5,6 +5,7 @@ import { parseRawSessionEvent } from "@src/engines/SessionCore/core/schemas"; import "@src/engines/SessionCore/sync/adapters"; import { getAdapterForSession } from "@src/engines/SessionCore/sync/types"; import { useSessionChannel } from "@src/engines/SessionCore/sync/useSessionChannel"; +import { isActiveStatus } from "@src/types/session/session"; const PENDING_MEMBER_SESSION_PREFIX = "agent-org-member-pending:"; @@ -47,7 +48,8 @@ export const AgentOrgGroupChatLiveSessions = memo( if (!enabled) return []; const ids = new Set(); for (const member of members) { - const sessionId = member.sessionRuntime?.sessionId; + const runtime = member.sessionRuntime; + const sessionId = runtime?.sessionId; if ( !sessionId || sessionId === excludeSessionId || @@ -55,6 +57,13 @@ export const AgentOrgGroupChatLiveSessions = memo( ) { continue; } + // Only subscribe IPC channels for sessions that are still active. + // Completed/failed/cancelled sessions will never emit again — holding + // a channel open for them wastes Rust registry slots and keeps the + // IPC bus busy for no reason. + if (!isActiveStatus(runtime?.status)) { + continue; + } ids.add(sessionId); } return [...ids]; diff --git a/src/engines/ChatPanel/hooks/useAiWorkItemCreator.ts b/src/engines/ChatPanel/hooks/useAiWorkItemCreator.ts index 4540b3283..0ae55ffa1 100644 --- a/src/engines/ChatPanel/hooks/useAiWorkItemCreator.ts +++ b/src/engines/ChatPanel/hooks/useAiWorkItemCreator.ts @@ -10,6 +10,10 @@ import { } from "@src/api/http/project"; import Message from "@src/components/Message"; import type { SessionLaunchSuccessInfo } from "@src/engines/SessionCore/hooks/session/useSessionCreator/useSessionLaunch/types"; +import { + allocateCloudAwareStandaloneWorkItemId, + allocateCloudAwareWorkItemId, +} from "@src/features/Org2Cloud/cloudShortId"; import i18n from "@src/i18n"; import type { AgentDefinition } from "@src/modules/MainApp/AgentOrgs/types"; import { SESSION_TARGET_KIND } from "@src/store/session"; @@ -18,6 +22,7 @@ import { CHAT_PANEL_CONTENT_MODE, CHAT_PANEL_CREATE_TARGET, type ChatPanelContentMode, + type ChatPanelCreateProjectContext, type ChatPanelCreateTarget, type ChatPanelSelectedProject, type ChatPanelSelectedWorkItem, @@ -33,6 +38,13 @@ interface AiWorkItemLaunchMetadata { projectSlug: string; projectId: string; projectName: string; + /** + * Project-org id a STANDALONE item was written under. The post-launch + * linked-session write MUST reuse it — an orgless rewrite would re-home + * the row to `personal-org` (the Rust upsert overwrites `org_id` on + * conflict) and detach it from collab sync. + */ + orgId?: string; item: WorkItemData; } @@ -56,6 +68,12 @@ interface ResolvedAiWorkItemAssignee { interface UseAiWorkItemCreatorOptions { allAgentDefs: AgentDefinition[]; + /** + * Org context of the create surface (set by NEW_WORK_ITEM navigation + * from an org hub). Standalone AI work items are written under this + * org so collab-synced orgs pick them up; null → personal-org. + */ + createProjectContext: ChatPanelCreateProjectContext | null; creatorState: SessionCreatorState; dispatchClearSession: () => void; setActiveSessionId: (sessionId: string | null) => void; @@ -72,6 +90,7 @@ interface UseAiWorkItemCreatorOptions { export function useAiWorkItemCreator({ allAgentDefs, + createProjectContext, creatorState, dispatchClearSession, setActiveSessionId, @@ -179,9 +198,19 @@ export function useAiWorkItemCreator({ const selectedProjectId = selectedProject?.meta.id ?? draft.projectId ?? ""; const selectedProjectName = selectedProject?.meta.name ?? ""; const now = new Date().toISOString(); + // Project-scoped ids go through the collab-aware allocator (design + // §16.5): server counter under a collab-synced org, local counter + // otherwise. Standalone work items have no project row, so they use + // the org-scoped local counter under the surface's org (documented + // residual in allocateCloudAwareStandaloneWorkItemId). + const draftOrgId = + draft.orgId && draft.orgId !== "personal-org" ? draft.orgId : undefined; + const standaloneOrgId = selectedProjectSlug + ? undefined + : (draftOrgId ?? createProjectContext?.orgId); const shortId = selectedProjectSlug - ? await projectApi.allocateWorkItemId(selectedProjectSlug) - : await projectApi.allocateStandaloneWorkItemId(); + ? await allocateCloudAwareWorkItemId(selectedProjectSlug) + : await allocateCloudAwareStandaloneWorkItemId(standaloneOrgId); const title = draft.name.trim() || AI_WORK_ITEM_DEFAULT_TITLE; const description = draft.description.trim(); const frontmatter: WorkItemFrontmatter = { @@ -227,7 +256,8 @@ export function useAiWorkItemCreator({ await projectApi.writeStandaloneWorkItem( shortId, frontmatter, - description + description, + standaloneOrgId ? { orgId: standaloneOrgId } : undefined ); } @@ -246,10 +276,15 @@ export function useAiWorkItemCreator({ projectSlug: selectedProjectSlug, projectId: selectedProjectId, projectName: selectedProjectName, + orgId: standaloneOrgId, item, }, }; - }, [resolveAiWorkItemAssignee, workItemCreateDraft]); + }, [ + createProjectContext?.orgId, + resolveAiWorkItemAssignee, + workItemCreateDraft, + ]); const handleAiWorkItemSessionStart = useCallback( async (info: SessionLaunchSuccessInfo) => { @@ -286,10 +321,13 @@ export function useAiWorkItemCreator({ { linkedSessions: [linkedSession] } ); } else { + // Same org scope as the creating write — an orgless rewrite would + // re-home the item to personal-org and detach it from collab sync. await projectApi.writeStandaloneWorkItem( metadata.shortId, updatedItem.frontmatter, - updatedItem.body + updatedItem.body, + metadata.orgId ? { orgId: metadata.orgId } : undefined ); } @@ -303,6 +341,7 @@ export function useAiWorkItemCreator({ projectSlug: metadata.projectSlug, projectId: metadata.projectId, projectName: metadata.projectName, + orgId: metadata.orgId, workItem, }); setShowWorkItemAgentCreator(sessionCreatorAvailable); diff --git a/src/engines/ChatPanel/hooks/useBrowserAddToConversationAction.ts b/src/engines/ChatPanel/hooks/useBrowserAddToConversationAction.ts new file mode 100644 index 000000000..2fca72400 --- /dev/null +++ b/src/engines/ChatPanel/hooks/useBrowserAddToConversationAction.ts @@ -0,0 +1,56 @@ +import { useAtomValue } from "jotai"; +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; + +import { + browserStatusBarCallbacksAtom, + browserStatusBarStateAtom, +} from "@src/store/ui/workStationAtom"; + +export interface UseBrowserAddToConversationActionReturn { + showAddToConversation: boolean; + addToConversationLabel: string; + addToConversationTooltipLabel: string; + cancelAddToConversationLabel: string; + onAddToConversation: () => void; + onCancelAddToConversation: () => void; +} + +const noop = () => undefined; + +export function useBrowserAddToConversationAction(): UseBrowserAddToConversationActionReturn { + const { t } = useTranslation("common"); + const browserStatus = useAtomValue(browserStatusBarStateAtom); + const browserCallbacks = useAtomValue(browserStatusBarCallbacksAtom); + + const addToConversationLabel = t("browser.selectedElement.addElement"); + const cancelAddToConversationLabel = t("actions.clearSelection"); + const selectedElementLabel = browserStatus.browserSelectedElementLabel; + const onSendSelectedElementToChat = + browserCallbacks.onSendSelectedElementToChat; + const onClearSelectedElement = browserCallbacks.onClearSelectedElement; + const showAddToConversation = + browserStatus.browserHasSelectedElement === true && + typeof onSendSelectedElementToChat === "function"; + + return useMemo( + () => ({ + showAddToConversation, + addToConversationLabel, + addToConversationTooltipLabel: selectedElementLabel + ? `${addToConversationLabel}: ${selectedElementLabel}` + : addToConversationLabel, + cancelAddToConversationLabel, + onAddToConversation: onSendSelectedElementToChat ?? noop, + onCancelAddToConversation: onClearSelectedElement ?? noop, + }), + [ + showAddToConversation, + addToConversationLabel, + cancelAddToConversationLabel, + selectedElementLabel, + onSendSelectedElementToChat, + onClearSelectedElement, + ] + ); +} diff --git a/src/engines/ChatPanel/hooks/useChatPanelContentState.tsx b/src/engines/ChatPanel/hooks/useChatPanelContentState.tsx index 3812032ff..24e7f7946 100644 --- a/src/engines/ChatPanel/hooks/useChatPanelContentState.tsx +++ b/src/engines/ChatPanel/hooks/useChatPanelContentState.tsx @@ -7,7 +7,7 @@ import { CHAT_PANEL_CREATE_TARGET, type ChatPanelContentMode, type ChatPanelCreateTarget, - type ChatPanelSelectedCollabOrg, + type ChatPanelSelectedCloudOrg, type ChatPanelSelectedProject, type ChatPanelSelectedProjectOrg, type ChatPanelSelectedWorkItem, @@ -20,17 +20,13 @@ interface UseChatPanelContentStateOptions { createTarget: ChatPanelCreateTarget; currentSessionId: string | null; exploreOpen: boolean; - isChatFocus: boolean; panelTitle: string; - collabOrgHeaderTitle?: string; - collabOrgHeaderTitleContent?: React.ReactNode; - selectedCollabOrg: ChatPanelSelectedCollabOrg | null; + cloudOrgHeaderTitle?: string; + selectedCloudOrg: ChatPanelSelectedCloudOrg | null; selectedProject: ChatPanelSelectedProject | null; selectedProjectOrg: ChatPanelSelectedProjectOrg | null; selectedWorkItem: ChatPanelSelectedWorkItem | null; selectedWorkspace: ChatPanelSelectedWorkspace | null; - workspaceDashboardOpen: boolean; - showChatFocusToggle: boolean; sidebarCollapsed: boolean; sessionCreatorAvailable: boolean; sessionSidebarVisible: boolean; @@ -43,9 +39,8 @@ export interface ChatPanelContentState { isProjectTarget: boolean; isWorkItemTarget: boolean; showBenchmarkSessionGroupContent: boolean; - showCollabOrgContent: boolean; + showCloudOrgContent: boolean; showCreatorPresenceInHeader: boolean; - showEmptyChatFocusRestoreButton: boolean; showExploreContent: boolean; showExplicitNonSessionContent: boolean; showHeader: boolean; @@ -58,7 +53,6 @@ export interface ChatPanelContentState { showSessionContent: boolean; showWorkItemAgentSwitchInHeader: boolean; showWorkItemContent: boolean; - showWorkspaceDashboardContent: boolean; showWorkspaceOverviewContent: boolean; } @@ -68,17 +62,13 @@ export function useChatPanelContentState({ createTarget, currentSessionId, exploreOpen, - isChatFocus, panelTitle, - collabOrgHeaderTitle, - collabOrgHeaderTitleContent, - selectedCollabOrg, + cloudOrgHeaderTitle, + selectedCloudOrg, selectedProject, selectedProjectOrg, selectedWorkItem, selectedWorkspace, - workspaceDashboardOpen, - showChatFocusToggle, sidebarCollapsed, sessionCreatorAvailable, sessionSidebarVisible, @@ -113,29 +103,20 @@ export function useChatPanelContentState({ !showSessionContent && !showWorkItemContent && !showProjectContent; - const showWorkspaceDashboardContent = - workspaceDashboardOpen && - !showBenchmarkSessionGroupContent && - !showSessionContent && - !showWorkItemContent && - !showProjectContent && - !showProjectOrgContent; const showExploreContent = exploreOpen && !showBenchmarkSessionGroupContent && !showSessionContent && !showWorkItemContent && !showProjectContent && - !showProjectOrgContent && - !showWorkspaceDashboardContent; - const showCollabOrgContent = - Boolean(selectedCollabOrg) && + !showProjectOrgContent; + const showCloudOrgContent = + Boolean(selectedCloudOrg) && !showBenchmarkSessionGroupContent && !showSessionContent && !showWorkItemContent && !showProjectContent && !showProjectOrgContent && - !showWorkspaceDashboardContent && !showExploreContent; const showWorkspaceOverviewContent = Boolean(selectedWorkspace) && @@ -144,9 +125,8 @@ export function useChatPanelContentState({ !showWorkItemContent && !showProjectContent && !showProjectOrgContent && - !showWorkspaceDashboardContent && !showExploreContent && - !showCollabOrgContent; + !showCloudOrgContent; const showExplicitNonSessionContent = contentMode === CHAT_PANEL_CONTENT_MODE.NON_SESSION; const showNonSessionContent = @@ -154,9 +134,8 @@ export function useChatPanelContentState({ !showWorkItemContent && !showProjectContent && !showProjectOrgContent && - !showWorkspaceDashboardContent && !showExploreContent && - !showCollabOrgContent && + !showCloudOrgContent && !showWorkspaceOverviewContent && !showSessionContent; const showPanelContent = @@ -165,9 +144,8 @@ export function useChatPanelContentState({ showWorkItemContent || showProjectContent || showProjectOrgContent || - showWorkspaceDashboardContent || showExploreContent || - showCollabOrgContent || + showCloudOrgContent || showWorkspaceOverviewContent || showExplicitNonSessionContent; const showHeader = @@ -175,9 +153,8 @@ export function useChatPanelContentState({ showWorkItemContent || showProjectContent || showProjectOrgContent || - showWorkspaceDashboardContent || showExploreContent || - showCollabOrgContent || + showCloudOrgContent || showWorkspaceOverviewContent || showExplicitNonSessionContent || (active && (showSessionContent || viewMode === "workStation")); @@ -198,18 +175,15 @@ export function useChatPanelContentState({ ? projectTitle : selectedProjectOrg ? projectOrgTitle - : showWorkspaceDashboardContent - ? t("navigation:launchpad.dashboard") - : showExploreContent - ? t("navigation:explore.title", { defaultValue: "Explore" }) - : showCollabOrgContent - ? (collabOrgHeaderTitle ?? - t("navigation:collaboration.orgDemoTitle")) - : createTarget === CHAT_PANEL_CREATE_TARGET.COLLAB_ORG - ? t("navigation:collaboration.addOrg") - : selectedWorkspace - ? workspaceTitle - : panelTitle; + : showExploreContent + ? t("navigation:explore.title", { defaultValue: "Explore" }) + : showCloudOrgContent + ? (cloudOrgHeaderTitle ?? t("navigation:cloud.title")) + : createTarget === CHAT_PANEL_CREATE_TARGET.COLLAB_ORG + ? t("navigation:collaboration.addOrg") + : selectedWorkspace + ? workspaceTitle + : panelTitle; const headerTitleContent = showWorkItemContent && selectedWorkItem ? ( @@ -236,11 +210,8 @@ export function useChatPanelContentState({ - ) : showCollabOrgContent && collabOrgHeaderTitleContent ? ( - collabOrgHeaderTitleContent - ) : showWorkspaceDashboardContent || - showExploreContent || - showCollabOrgContent || + ) : showExploreContent || + showCloudOrgContent || showWorkspaceOverviewContent ? ( void; +} + +export function useChatPanelHeaderActions({ + handleReloadSession, +}: UseChatPanelHeaderActionsOptions) { + const openSearchRef = useRef<(() => void) | null>(null); + const { + isOpen: isHeaderActionsOpen, + isPositioned: isHeaderActionsPositioned, + toggle: toggleHeaderActionsMenu, + close: closeHeaderActionsMenu, + triggerRef: headerActionsTriggerRef, + panelRef: headerActionsDropdownRef, + panelPosition: headerActionsPosition, + } = useDropdownEngine({ + gap: 4, + align: "right", + placement: "bottom", + }); + + const [paginationEnabled, setPaginationEnabled] = useAtom( + chatTurnPaginationEnabledAtom + ); + const [displayMode, setDisplayMode] = useAtom(chatHistoryDisplayModeAtom); + const [tokenUsageVisible, setTokenUsageVisible] = useAtom( + chatTokenUsageVisibleAtom + ); + const [statusBarVisible, setStatusBarVisible] = useAtom( + chatStatusBarVisibleAtom + ); + const [exploreAgentSearchEnabled, setExploreAgentSearchEnabled] = useAtom( + chatPanelExploreAgentSearchEnabledAtom + ); + const collapseAllCommand = useAtomValue(collapseAllCommandAtom); + const setAllBlocksCollapsed = useSetAtom(setAllBlocksCollapsedAtom); + const eventCount = useAtomValue(eventCountAtom); + const events = useAtomValue(eventsAtom); + const [copyEventJsonLabel, setCopyEventJsonLabel] = useState< + "idle" | "copied" | "failed" + >("idle"); + + const allBlocksCollapsed = + collapseAllCommand.epoch > 0 ? collapseAllCommand.collapsed : false; + + const handleToggleAllBlocksCollapsed = useCallback(() => { + setAllBlocksCollapsed(!allBlocksCollapsed); + closeHeaderActionsMenu(); + }, [allBlocksCollapsed, closeHeaderActionsMenu, setAllBlocksCollapsed]); + + const handleRegisterSearchOpen = useCallback( + (handler: (() => void) | null) => { + openSearchRef.current = handler; + }, + [] + ); + + const handleOpenSearch = useCallback(() => { + openSearchRef.current?.(); + closeHeaderActionsMenu(); + }, [closeHeaderActionsMenu]); + + const handleReloadFromMenu = useCallback(() => { + handleReloadSession(); + closeHeaderActionsMenu(); + }, [closeHeaderActionsMenu, handleReloadSession]); + + const handlePaginationToggle = useCallback( + (checked: boolean) => { + setPaginationEnabled(checked); + }, + [setPaginationEnabled] + ); + + const handleExploreAgentSearchToggle = useCallback( + (checked: boolean) => { + setExploreAgentSearchEnabled(checked); + }, + [setExploreAgentSearchEnabled] + ); + + const handleCompactDisplayModeToggle = useCallback( + (checked: boolean) => { + setDisplayMode(checked ? "compact" : "full"); + }, + [setDisplayMode] + ); + + const handleTokenUsageVisibleToggle = useCallback( + (checked: boolean) => { + setTokenUsageVisible(checked); + }, + [setTokenUsageVisible] + ); + + const handleStatusBarVisibleToggle = useCallback( + (checked: boolean) => { + setStatusBarVisible(checked); + }, + [setStatusBarVisible] + ); + + const handleCopyEventJson = useCallback(() => { + const json = JSON.stringify(events, null, 2); + navigator.clipboard + .writeText(json) + .then(() => { + setCopyEventJsonLabel("copied"); + setTimeout(() => setCopyEventJsonLabel("idle"), 2000); + }) + .catch(() => { + setCopyEventJsonLabel("failed"); + setTimeout(() => setCopyEventJsonLabel("idle"), 2000); + }); + closeHeaderActionsMenu(); + }, [closeHeaderActionsMenu, events]); + + return { + allBlocksCollapsed, + closeHeaderActionsMenu, + copyEventJsonLabel, + displayMode, + eventCount, + exploreAgentSearchEnabled, + handleCompactDisplayModeToggle, + handleCopyEventJson, + handleExploreAgentSearchToggle, + handleOpenSearch, + handlePaginationToggle, + handleRegisterSearchOpen, + handleReloadFromMenu, + handleStatusBarVisibleToggle, + handleToggleAllBlocksCollapsed, + handleTokenUsageVisibleToggle, + headerActionsDropdownRef, + headerActionsPosition, + headerActionsTriggerRef, + isHeaderActionsOpen, + isHeaderActionsPositioned, + paginationEnabled, + statusBarVisible, + tokenUsageVisible, + toggleHeaderActionsMenu, + }; +} diff --git a/src/engines/ChatPanel/hooks/useChatPanelNavigationActions.ts b/src/engines/ChatPanel/hooks/useChatPanelNavigationActions.ts new file mode 100644 index 000000000..920d9c5bc --- /dev/null +++ b/src/engines/ChatPanel/hooks/useChatPanelNavigationActions.ts @@ -0,0 +1,63 @@ +import { useSetAtom } from "jotai"; +import { useCallback } from "react"; + +import { clearSessionAtom } from "@src/engines/SessionCore/core/atoms"; +import { + activeSessionIdAtom, + workstationActiveSessionIdAtom, +} from "@src/store/session"; +import { + CHAT_PANEL_SURFACE_KIND, + chatPanelNavigateAtom, + chatPanelStartPageOpenAtom, +} from "@src/store/ui/chatPanelAtom"; + +export function useChatPanelNavigationActions() { + const setStartPageOpen = useSetAtom(chatPanelStartPageOpenAtom); + const navigateChatPanel = useSetAtom(chatPanelNavigateAtom); + const dispatchClearSession = useSetAtom(clearSessionAtom); + const setWorkstationActiveSessionId = useSetAtom( + workstationActiveSessionIdAtom + ); + const setActiveSessionId = useSetAtom(activeSessionIdAtom); + + const resetActiveSession = useCallback(() => { + dispatchClearSession(); + setWorkstationActiveSessionId(null); + setActiveSessionId(null); + }, [dispatchClearSession, setActiveSessionId, setWorkstationActiveSessionId]); + + const showSessionSurface = useCallback(() => { + setStartPageOpen(false); + navigateChatPanel({ kind: CHAT_PANEL_SURFACE_KIND.SESSION }); + }, [navigateChatPanel, setStartPageOpen]); + + const resetToSessionSurface = useCallback(() => { + showSessionSurface(); + resetActiveSession(); + }, [resetActiveSession, showSessionSurface]); + + const openWorkItemCreate = useCallback(() => { + setStartPageOpen(false); + navigateChatPanel({ kind: CHAT_PANEL_SURFACE_KIND.NEW_WORK_ITEM }); + resetActiveSession(); + }, [navigateChatPanel, resetActiveSession, setStartPageOpen]); + + const openWorkspaceExplore = useCallback(() => { + setStartPageOpen(false); + navigateChatPanel({ kind: CHAT_PANEL_SURFACE_KIND.WORKSPACE_EXPLORE }); + resetActiveSession(); + }, [navigateChatPanel, resetActiveSession, setStartPageOpen]); + + return { + dispatchClearSession, + openWorkItemCreate, + openWorkspaceExplore, + resetActiveSession, + resetToSessionSurface, + setActiveSessionId, + setStartPageOpen, + setWorkstationActiveSessionId, + showSessionSurface, + }; +} diff --git a/src/engines/ChatPanel/hooks/useChatPanelResize.ts b/src/engines/ChatPanel/hooks/useChatPanelResize.ts index 1a6305b0c..61fb82523 100644 --- a/src/engines/ChatPanel/hooks/useChatPanelResize.ts +++ b/src/engines/ChatPanel/hooks/useChatPanelResize.ts @@ -3,8 +3,8 @@ * * Ultra-optimized resize logic using RAF (requestAnimationFrame). * - Only updates once per frame - * - Only changes panel width, NOT CSS variable during drag - * - CSS variable only updates on mouseup + * - Updates one live width channel during drag: CHAT_WIDTH_CSS_VAR + * - Persists the CSS variable width to atom/storage only on mouseup * - Ignores clicks without actual drag to prevent accidental resize * * INVARIANT: drag minimizes, never closes. The handle clamps to MIN_WIDTH @@ -26,25 +26,24 @@ import { useState, } from "react"; -import { DEFAULT_CHAT_WIDTH, chatWidthAtom } from "@src/store/ui/chatPanelAtom"; +import { + DEFAULT_CHAT_WIDTH, + chatPanelDraggingAtom, + chatWidthAtom, +} from "@src/store/ui/chatPanelAtom"; import { CHAT_WIDTH_CSS_VAR, - LEFT_PANEL_WIDTH, - MAX_WIDTH, - MIN_CENTER_WIDTH, MIN_WIDTH, RAPID_CLICK_THRESHOLD_MS, + clampChatWidth, + getChatMaxWidth, } from "../config"; -// Clamp a width value to [0, MAX_WIDTH] (0 is the valid "hidden" sentinel). -const clampChatWidth = (value: number): number => - value > 0 ? Math.min(value, MAX_WIDTH) : value; - export interface UseChatPanelResizeOptions { /** Whether using external width control */ useExternalWidth?: boolean; - /** Whether in embedded mode */ + /** Whether the panel is rendered inside another app surface */ embedded?: boolean; /** Panel position: left or right */ position?: "left" | "right"; @@ -59,7 +58,7 @@ export interface UseChatPanelResizeResult { handleMouseDown: (event: ReactMouseEvent) => void; } -// Helper to get current width from CSS variable, clamped to MAX_WIDTH. +// Helper to get current width from CSS variable, clamped to the responsive max. const getChatWidthFromCSS = (): number => { if (typeof document === "undefined") return DEFAULT_CHAT_WIDTH; const cssValue = @@ -77,16 +76,13 @@ const getChatWidthFromCSS = (): number => { export function useChatPanelResize( options: UseChatPanelResizeOptions = {} ): UseChatPanelResizeResult { - const { - useExternalWidth = false, - embedded = false, - position = "right", - } = options; + const { useExternalWidth = false, position = "right" } = options; const isLeftPosition = position === "left"; // OPTIMIZED: Only use setter, don't subscribe to value changes // Width is read from CSS variable when needed const setChatWidth = useSetAtom(chatWidthAtom); + const setChatPanelDragging = useSetAtom(chatPanelDraggingAtom); const [isDragging, setIsDragging] = useState(false); // Refs for pure DOM resize (no React renders during drag) @@ -126,54 +122,38 @@ export function useChatPanelResize( pendingWidthRef.current = currentWidth; hasDraggedRef.current = false; - // Cache element references at drag start for better performance. - // Use data-attribute queries instead of brittle parentElement traversal - // so the lookup is resilient to DOM depth differences across layouts. - const cachedMainContent = !embedded - ? (document.querySelector("[data-main-content]") as HTMLElement | null) - : null; - - // Inset overlay wrapper: the direct parent of the ChatPanel root that - // sits inside [data-main-content]. We find it by walking up from panelRef - // until we hit [data-main-content]'s direct child. - let cachedInsetOverlay: HTMLElement | null = null; - if (!embedded && panelRef.current && cachedMainContent) { - let node: HTMLElement | null = panelRef.current; - while (node && node.parentElement !== cachedMainContent) { - node = node.parentElement; - } - cachedInsetOverlay = node; - } + const applyLiveWidth = (width: number) => { + document.documentElement.style.setProperty( + CHAT_WIDTH_CSS_VAR, + `${width}px` + ); + }; - // For embedded (full) mode, find the chat wrapper by data attribute. - let cachedChatWrapper: HTMLElement | null = null; - if (embedded && panelRef.current) { - cachedChatWrapper = panelRef.current.closest( - "[data-fullmode-chat-wrapper]" - ) as HTMLElement | null; - } + const commitPendingWidth = () => { + const rawFinal = pendingWidthRef.current; + const finalWidth = clampChatWidth( + Number.isFinite(rawFinal) && rawFinal >= MIN_WIDTH + ? rawFinal + : MIN_WIDTH + ); + + applyLiveWidth(finalWidth); + setChatWidth(finalWidth); + }; const handleMouseMove = (moveEvent: globalThis.MouseEvent) => { // Only start "dragging" mode after first actual movement if (!hasDraggedRef.current) { hasDraggedRef.current = true; setIsDragging(true); + setChatPanelDragging(true); // Set cursor globally - only when actually dragging document.body.style.cursor = "ew-resize"; document.body.style.userSelect = "none"; } - // Calculate dynamic max width based on available space. Guard - // against undersized viewports where `availableWidth - MIN_CENTER_WIDTH` - // would fall below MIN_WIDTH and make the `Math.min` below try to - // shrink the panel past its minimum — we always want a valid - // non-collapsing range. - const availableWidth = window.innerWidth - LEFT_PANEL_WIDTH - 20; - const dynamicMaxWidth = Math.max( - MIN_WIDTH, - Math.min(MAX_WIDTH, availableWidth - MIN_CENTER_WIDTH) - ); + const dynamicMaxWidth = getChatMaxWidth(); // Calculate new width with constraints. Minimum is enforced as the // LAST step so nothing (negative delta, NaN, subzero dynamicMax, …) @@ -193,26 +173,7 @@ export function useChatPanelResize( // Schedule DOM update for next frame rafRef.current = requestAnimationFrame(() => { - if (panelRef.current) { - panelRef.current.style.width = `${newWidth}px`; - } - - if (embedded) { - if (cachedChatWrapper) { - cachedChatWrapper.style.width = `${newWidth}px`; - } - } else { - if (cachedInsetOverlay) { - cachedInsetOverlay.style.width = `${newWidth}px`; - } - - if (cachedMainContent) { - const paddingProp = isLeftPosition - ? "paddingLeft" - : "paddingRight"; - cachedMainContent.style[paddingProp] = `${newWidth + 12}px`; - } - } + applyLiveWidth(newWidth); }); }; @@ -229,55 +190,9 @@ export function useChatPanelResize( document.body.style.cursor = ""; document.body.style.userSelect = ""; - // Final clamp on commit: the drag handle can only minimize the - // panel, never close it. If pendingWidthRef somehow holds a - // sub-minimum value (NaN, stale from a previous drag, etc.) we - // coerce it up to MIN_WIDTH here so the persisted width never - // collapses the panel. - const rawFinal = pendingWidthRef.current; - const finalWidth = clampChatWidth( - Number.isFinite(rawFinal) && rawFinal >= MIN_WIDTH - ? rawFinal - : MIN_WIDTH - ); - - if (embedded) { - if (panelRef.current) { - panelRef.current.style.width = ""; - } - if (cachedChatWrapper) { - cachedChatWrapper.style.width = ""; - } - - document.documentElement.style.setProperty( - CHAT_WIDTH_CSS_VAR, - `${finalWidth}px` - ); - setChatWidth(finalWidth); - } else { - if (panelRef.current) { - panelRef.current.style.width = ""; - } - if (cachedInsetOverlay) { - cachedInsetOverlay.style.width = ""; - } - - if (cachedMainContent) { - if (isLeftPosition) { - cachedMainContent.style.paddingLeft = ""; - } else { - cachedMainContent.style.paddingRight = ""; - } - } - - document.documentElement.style.setProperty( - CHAT_WIDTH_CSS_VAR, - `${finalWidth}px` - ); - setChatWidth(finalWidth); - } - + commitPendingWidth(); setIsDragging(false); + setChatPanelDragging(false); hasDraggedRef.current = false; } }; @@ -289,30 +204,18 @@ export function useChatPanelResize( document.removeEventListener("mouseup", handleMouseUp); document.body.style.cursor = ""; document.body.style.userSelect = ""; - if (panelRef.current) { - panelRef.current.style.width = ""; - } - if (cachedInsetOverlay) { - cachedInsetOverlay.style.width = ""; - } - if (cachedChatWrapper) { - cachedChatWrapper.style.width = ""; - } - if (cachedMainContent) { - if (isLeftPosition) { - cachedMainContent.style.paddingLeft = ""; - } else { - cachedMainContent.style.paddingRight = ""; - } + if (hasDraggedRef.current) { + commitPendingWidth(); } hasDraggedRef.current = false; setIsDragging(false); + setChatPanelDragging(false); }; document.addEventListener("mousemove", handleMouseMove); document.addEventListener("mouseup", handleMouseUp); }, - [embedded, isLeftPosition, setChatWidth, useExternalWidth] + [isLeftPosition, setChatPanelDragging, setChatWidth, useExternalWidth] ); // Cleanup drag listeners on unmount diff --git a/src/engines/ChatPanel/hooks/useChatPanelSessionModals.tsx b/src/engines/ChatPanel/hooks/useChatPanelSessionModals.tsx index a96e12f49..0ebd2ac9e 100644 --- a/src/engines/ChatPanel/hooks/useChatPanelSessionModals.tsx +++ b/src/engines/ChatPanel/hooks/useChatPanelSessionModals.tsx @@ -3,7 +3,10 @@ import type { TFunction } from "i18next"; import { type ComponentProps, useCallback, useState } from "react"; import Message from "@src/components/Message"; +import CloudSessionShareDialog from "@src/features/Org2Cloud/CloudSessionShareDialog"; +import { useCloudSessionShareDialog } from "@src/features/Org2Cloud/CloudSessionShareDialog/useCloudSessionShareDialog"; import { SessionImportExportModal } from "@src/scaffold/NavigationSidebar/connectors/SessionImportExportModal"; +import type { Session } from "@src/store/session/sessionAtom/types"; import LinkSessionToWorkItemModal from "../panels/LinkSessionToWorkItemModal"; @@ -14,6 +17,8 @@ type ExportActiveSession = ComponentProps< interface UseChatPanelSessionModalsOptions { activeSession: ExportActiveSession; closeHeaderActionsMenu: () => void; + /** Full session row for the share dialog (design §6.3 header mount). */ + currentSession: Session | null; currentSessionId: string | null; t: TFunction<["sessions", "common", "projects", "navigation"]>; } @@ -21,11 +26,13 @@ interface UseChatPanelSessionModalsOptions { export function useChatPanelSessionModals({ activeSession, closeHeaderActionsMenu, + currentSession, currentSessionId, t, }: UseChatPanelSessionModalsOptions) { const [isExportModalOpen, setExportModalOpen] = useState(false); const [isLinkWorkItemModalOpen, setLinkWorkItemModalOpen] = useState(false); + const cloudShare = useCloudSessionShareDialog(); const handleOpenExportSessionJson = useCallback(() => { setExportModalOpen(true); @@ -53,6 +60,18 @@ export function useChatPanelSessionModals({ void emit("orgii-data-changed", new Date().toISOString()); }, []); + // Cloud share dialog (0012), session-header mount: only for the + // owner's own pushable session that belongs to ≥1 cloud org. + const showCloudShareSettings = Boolean( + currentSession && cloudShare.isCloudShareEligible(currentSession) + ); + + const handleOpenCloudShareSettings = useCallback(() => { + if (!currentSession) return; + cloudShare.openCloudShare(currentSession); + closeHeaderActionsMenu(); + }, [closeHeaderActionsMenu, cloudShare, currentSession]); + const sessionModals = ( <> undefined} /> + ); return { handleOpenExportSessionJson, handleOpenLinkWorkItem, + handleOpenCloudShareSettings, + showCloudShareSettings, sessionModals, }; } diff --git a/src/engines/ChatPanel/hooks/useChatPanelState.ts b/src/engines/ChatPanel/hooks/useChatPanelState.ts index 65d81ecaa..013ac319e 100644 --- a/src/engines/ChatPanel/hooks/useChatPanelState.ts +++ b/src/engines/ChatPanel/hooks/useChatPanelState.ts @@ -16,7 +16,7 @@ * chatHistory, * } = useChatPanelState(); */ -import { useAtom, useAtomValue } from "jotai"; +import { useAtom } from "jotai"; import { useCallback, useState } from "react"; import { @@ -26,8 +26,6 @@ import { import { useDataContext } from "@src/contexts/workspace/DataContext"; import { useTaskStatus } from "@src/engines/SessionCore"; import type { SessionEvent } from "@src/engines/SessionCore/core/types"; -import { useTodoSync } from "@src/engines/SessionCore/hooks/session/useTodoSync"; -import { activeSessionIdAtom } from "@src/store/session"; import { chatDropDownShowAtom } from "@src/store/ui/chatPanelAtom"; // ============================================ @@ -93,16 +91,8 @@ export function useChatPanelState(): UseChatPanelReturn { // Global State // ============================================ - const sessionId = useAtomValue(activeSessionIdAtom); const [chatDropDownShow, setChatDropDownShow] = useAtom(chatDropDownShowAtom); - // ============================================ - // Todo Sync (for StickyTaskList) - // ============================================ - - // Sync manage_todo events to the sticky task list atom - useTodoSync(sessionId || undefined); - // ============================================ // Local State // ============================================ diff --git a/src/engines/ChatPanel/hooks/useChatPanelTabsController.ts b/src/engines/ChatPanel/hooks/useChatPanelTabsController.ts new file mode 100644 index 000000000..a3c1a003a --- /dev/null +++ b/src/engines/ChatPanel/hooks/useChatPanelTabsController.ts @@ -0,0 +1,114 @@ +import { useAtomValue, useSetAtom } from "jotai"; +import { useCallback, useEffect } from "react"; + +import { + activeChatPanelTabAtom, + addChatPanelTerminalTabAtom, + chatPanelTabsAtom, + openKanbanChatPanelTabAtom, + openOrFocusChatPanelStartPageTabAtom, + setChatPanelTabSessionIdAtom, +} from "@src/store/chatPanel/chatPanelTabsAtom"; +import { createChatPanelTerminalAtom } from "@src/store/chatPanel/chatPanelTerminalAtom"; +import { WORK_MANAGEMENT_SECTION } from "@src/store/workstation"; + +import type { ChatPanelCliTerminalLaunchOptions } from "../types"; + +interface UseChatPanelTabsControllerOptions { + currentSessionId: string | null; + launchpadTitle: string; + kanbanTitle: string; + showSessionSurface: () => void; +} + +export function useChatPanelTabsController({ + currentSessionId, + launchpadTitle, + kanbanTitle, + showSessionSurface, +}: UseChatPanelTabsControllerOptions) { + const setTabSessionId = useSetAtom(setChatPanelTabSessionIdAtom); + const activeTab = useAtomValue(activeChatPanelTabAtom); + const allTabs = useAtomValue(chatPanelTabsAtom).tabs; + const openStartPageTab = useSetAtom(openOrFocusChatPanelStartPageTabAtom); + const addTerminalTab = useSetAtom(addChatPanelTerminalTabAtom); + const openKanbanTab = useSetAtom(openKanbanChatPanelTabAtom); + const createTerminalSession = useSetAtom(createChatPanelTerminalAtom); + const activeTabId = activeTab?.id; + const activeTabSessionId = activeTab?.sessionId; + const activeTabType = activeTab?.type; + + useEffect(() => { + if (!activeTabId || activeTabType !== "session") return; + if (!activeTabSessionId && currentSessionId) { + setTabSessionId({ + tabId: activeTabId, + sessionId: currentSessionId, + }); + } + }, [ + activeTabId, + activeTabSessionId, + activeTabType, + currentSessionId, + setTabSessionId, + ]); + + const handleNewTerminalTab = useCallback(() => { + const terminalSessionId = createTerminalSession("Terminal"); + addTerminalTab(terminalSessionId); + }, [addTerminalTab, createTerminalSession]); + + const handleOpenCliTerminal = useCallback( + (options: ChatPanelCliTerminalLaunchOptions) => { + const terminalSessionId = createTerminalSession({ + name: options.title, + cwd: options.cwd, + cliAgentType: options.cliAgentType, + agentCommand: options.command, + expectedProcess: options.expectedProcess, + agentSessionId: options.agentSessionId, + }); + addTerminalTab({ + terminalSessionId, + title: options.title, + cliCommand: options.command, + }); + showSessionSurface(); + }, + [addTerminalTab, createTerminalSession, showSessionSurface] + ); + + // New-session and launchpad both open the singleton start page (Work + // section), focusing the existing tab instead of stacking a new one. + const handleNewSessionTab = useCallback(() => { + openStartPageTab({ title: launchpadTitle }); + }, [openStartPageTab, launchpadTitle]); + + const handleOpenLaunchpadTab = useCallback(() => { + openStartPageTab({ title: launchpadTitle }); + }, [openStartPageTab, launchpadTitle]); + + const handleOpenKanbanTab = useCallback(() => { + openKanbanTab({ + section: WORK_MANAGEMENT_SECTION.KANBAN, + title: kanbanTitle, + }); + }, [kanbanTitle, openKanbanTab]); + + const isTerminalTabActive = activeTab?.type === "terminal"; + const terminalTabs = allTabs.filter( + (tab) => tab.type === "terminal" && tab.terminalSessionId + ); + + return { + activeTab, + handleNewSessionTab, + handleNewTerminalTab, + handleOpenCliTerminal, + handleOpenLaunchpadTab, + handleOpenKanbanTab, + isTerminalTabActive, + terminalTabs, + }; +} diff --git a/src/engines/ChatPanel/hooks/useInputArea/index.ts b/src/engines/ChatPanel/hooks/useInputArea/index.ts index 9d2058c7d..7d9948d7e 100644 --- a/src/engines/ChatPanel/hooks/useInputArea/index.ts +++ b/src/engines/ChatPanel/hooks/useInputArea/index.ts @@ -20,10 +20,18 @@ * } = useInputArea(); */ import { useAtomValue } from "jotai"; -import { useCallback, useEffect, useId, useMemo, useRef } from "react"; +import { + useCallback, + useEffect, + useId, + useMemo, + useRef, + useState, +} from "react"; import { useChatContext } from "@src/contexts/workspace/ChatContext"; import { useDataContext } from "@src/contexts/workspace/DataContext"; +import { parseCompactSlashCommand } from "@src/engines/ChatPanel/hooks/useManualCompact"; import useWorkspaceChat from "@src/engines/ChatPanel/hooks/useWorkspaceChat"; import { useRepositoryInfo } from "@src/engines/SessionCore"; import { sortedEventsAtom } from "@src/engines/SessionCore/core/atoms/events"; @@ -47,6 +55,7 @@ import { sessionByIdAtom } from "@src/store/session/sessionAtom/atoms"; import { wpReadOnlyAtom } from "@src/store/ui/chatPanelAtom"; import { workspaceFoldersAtom } from "@src/store/ui/workspaceFoldersAtom"; import { activeWorkspaceRootPathAtom } from "@src/store/workspace"; +import { getCompactPathLabel } from "@src/util/file/pathUtils"; import { formatRepoPathForDisplay } from "@src/util/file/repoPathDisplay"; import { useCurrentTheme } from "@src/util/ui/theme/themeUtils"; @@ -74,6 +83,7 @@ import { useImageAttachment } from "./useImageAttachment"; import { useInputAreaEffects } from "./useInputAreaEffects"; import { useInputAreaRefs } from "./useInputAreaRefs"; import { useInputAreaState } from "./useInputAreaState"; +import { usePromptPolish } from "./usePromptPolish"; import { useSlashCommand } from "./useSlashCommand"; import { useSubmitMessage } from "./useSubmitMessage"; import { useUploadContext } from "./useUploadContext"; @@ -107,12 +117,6 @@ function getDraftRestoreSkipReason(draftText: string): string | null { return null; } -function getCompactPathLabel(path: string): string { - const normalizedPath = path.replace(/\\/g, "/"); - const parts = normalizedPath.split("/").filter(Boolean); - return parts.slice(-3).join("/") || normalizedPath; -} - function getPlanMentionPath(event: SessionEvent): string | null { const planPath = getPlanDocViewModel(event).planPath; return planPath && planPath.trim() ? planPath : null; @@ -383,6 +387,7 @@ export function useInputArea( setShowSlashMenu: state.setShowSlashMenu, setSlashQuery: state.setSlashQuery, workspacePaths: skillWorkspacePaths, + sessionId: activeSessionId, }); const imageAttachment = useImageAttachment(dropTargetId); @@ -448,6 +453,15 @@ export function useInputArea( composerInputRef: refs.composerInputRef, }); + const promptPolish = usePromptPolish({ + refs, + draftSessionId, + setDraft, + handleSessInputChange, + withProgrammaticInputMutation, + }); + const handlePromptPolishContentChange = promptPolish.handleContentChange; + // ============================================ // Effects // ============================================ @@ -476,6 +490,8 @@ export function useInputArea( state.setIsInputFocused(false); }, [state]); + const [compactHintVisible, setCompactHintVisible] = useState(false); + const handleContentChange = useCallback( (text: string) => { const draftText = @@ -483,6 +499,15 @@ export function useInputArea( const cleanedText = draftText.trim(); refs.setHasContent(cleanedText.length > 0); + // Argument ghost hint for `/compact`: visible while the draft is a + // compact command with no focus text yet (pill or typed form). + { + const compactDraft = parseCompactSlashCommand(cleanedText); + setCompactHintVisible( + compactDraft !== null && !compactDraft.instructions + ); + } + // Pass to workspace chat handler handleSessInputChange(draftText); @@ -490,6 +515,8 @@ export function useInputArea( return; } + handlePromptPolishContentChange(); + // Mirror the latest text onto the session row (debounced). Skip // when we don't have an active session id — the composer is only // mounted once we've resolved one in practice, but the ref form @@ -499,7 +526,13 @@ export function useInputArea( setDraft(draftText); } }, - [draftSessionId, handleSessInputChange, refs, setDraft] + [ + draftSessionId, + handlePromptPolishContentChange, + handleSessInputChange, + refs, + setDraft, + ] ); // Restore the persisted draft into the editor on session switch. @@ -637,9 +670,11 @@ export function useInputArea( setIsInputFocused: state.setIsInputFocused, handleInputBlur, handleContentChange, + compactHintVisible, handleAtMention: atMention.handleAtMention, handleAtMentionClose: atMention.handleAtMentionClose, isInputEmpty, + promptPolish, // @ Mention showContextMenu: state.showContextMenu, @@ -662,6 +697,7 @@ export function useInputArea( filteredSlashItems: slashCommand.filteredItems, slashLoading: slashCommand.slashLoading, prefetchSlashItems: slashCommand.prefetchItems, + addressCommentsFlyout: slashCommand.addressCommentsFlyout, // File selection handleSelectFile: fileSelection.handleSelectFile, diff --git a/src/engines/ChatPanel/hooks/useInputArea/types.ts b/src/engines/ChatPanel/hooks/useInputArea/types.ts index 4a0825dd8..2e1b8b46a 100644 --- a/src/engines/ChatPanel/hooks/useInputArea/types.ts +++ b/src/engines/ChatPanel/hooks/useInputArea/types.ts @@ -15,6 +15,8 @@ import type { MenuItemId } from "@src/scaffold/ContextMenu/config"; import type { ChatImageAttachment } from "@src/store/ui/chatImageAtom"; import type { SlashItem } from "@src/types/extensions/types"; +import type { AddressCommentsFlyoutData } from "./useSlashCommand"; + // ============================================ // Options // ============================================ @@ -139,6 +141,17 @@ export interface DragDropHandlers { handleDrop: (e: DragEvent) => void; } +export type PromptPolishStatus = "idle" | "polishing" | "polished"; + +export interface PromptPolishControl { + status: PromptPolishStatus; + isAvailable: boolean; + isPolishing: boolean; + isPolished: boolean; + toggle: () => Promise; + reset: () => void; +} + // ============================================ // Main Return Type // ============================================ @@ -164,9 +177,12 @@ export interface UseInputAreaReturn { setIsInputFocused: (focused: boolean) => void; handleInputBlur: () => void; handleContentChange: (text: string) => void; + /** True while the draft is a `/compact` command with no focus text yet. */ + compactHintVisible: boolean; handleAtMention: (query: string, position: { x: number; y: number }) => void; handleAtMentionClose: () => void; isInputEmpty: () => boolean; + promptPolish: PromptPolishControl; // Context Menu showContextMenu: boolean; @@ -193,6 +209,7 @@ export interface UseInputAreaReturn { filteredSlashItems: SlashItem[]; slashLoading: boolean; prefetchSlashItems: (query: string) => void; + addressCommentsFlyout?: AddressCommentsFlyoutData; // File selection handleSelectFile: (file: string) => void; diff --git a/src/engines/ChatPanel/hooks/useInputArea/useAtMention.ts b/src/engines/ChatPanel/hooks/useInputArea/useAtMention.ts index b1cd8b50b..781e69eb3 100644 --- a/src/engines/ChatPanel/hooks/useInputArea/useAtMention.ts +++ b/src/engines/ChatPanel/hooks/useInputArea/useAtMention.ts @@ -119,7 +119,7 @@ export function useAtMention(options: UseAtMentionOptions): AtMentionHandlers { const buffer = getTerminalBuffer(ptySessionId); if (buffer) { const capped = capPillText(buffer); - const lineCount = capped.split("\n").length; + const lineCount = capped.trimEnd().split("\n").length; const pillPath = `terminal://${value}/${Date.now()}`; const pillDisplayName = lineCount > 1 diff --git a/src/engines/ChatPanel/hooks/useInputArea/usePromptPolish.ts b/src/engines/ChatPanel/hooks/useInputArea/usePromptPolish.ts new file mode 100644 index 000000000..90a5040bf --- /dev/null +++ b/src/engines/ChatPanel/hooks/useInputArea/usePromptPolish.ts @@ -0,0 +1,212 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { promptPolish as requestPromptPolish } from "@src/api/services/keyValidation"; +import type { ComposerSnapshot } from "@src/components/ComposerInput/types"; +import Message from "@src/components/Message"; +import { useHousekeeperConfig } from "@src/hooks/housekeeper"; + +import { + polishedTextToSnapshot, + snapshotHasPolishableText, + snapshotSignature, + snapshotToPolishText, +} from "../../InputArea/promptPolish/snapshotPlaceholders"; +import type { + InputAreaRefs, + PromptPolishControl, + PromptPolishStatus, +} from "./types"; + +interface UsePromptPolishOptions { + refs: InputAreaRefs; + draftSessionId: string; + setDraft: (text: string) => void; + handleSessInputChange: (text: string) => void; + withProgrammaticInputMutation: (mutation: () => void) => void; +} + +interface PromptPolishRestoreState { + sessionId: string; + originalSnapshot: ComposerSnapshot; + polishedSignature: string; +} + +interface PromptPolishState { + sessionId: string; + status: PromptPolishStatus; +} + +function formatPromptPolishError(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + return message.replace(/^\[RPC:prompt_polish\]\s*/, ""); +} + +export function usePromptPolish({ + refs, + draftSessionId, + setDraft, + handleSessInputChange, + withProgrammaticInputMutation, +}: UsePromptPolishOptions): PromptPolishControl & { + handleContentChange: () => void; +} { + const [state, setState] = useState({ + sessionId: draftSessionId, + status: "idle", + }); + const restoreStateRef = useRef(null); + const draftSessionIdRef = useRef(draftSessionId); + const housekeeper = useHousekeeperConfig(); + + useEffect(() => { + draftSessionIdRef.current = draftSessionId; + }, [draftSessionId]); + + const status = + state.sessionId === draftSessionId ? state.status : ("idle" as const); + const isAvailable = housekeeper.enabled && housekeeper.features.promptPolish; + + const setStatus = useCallback( + (nextStatus: PromptPolishStatus) => { + setState({ sessionId: draftSessionId, status: nextStatus }); + }, + [draftSessionId] + ); + + const reset = useCallback(() => { + restoreStateRef.current = null; + setStatus("idle"); + }, [setStatus]); + + const applyComposerContent = useCallback( + (content: string | ComposerSnapshot): string | null => { + const editor = refs.composerInputRef.current; + if (!editor) return null; + + withProgrammaticInputMutation(() => { + editor.setContent(content); + }); + + const displayText = editor.getTextWithPills(); + refs.setHasContent(displayText.trim().length > 0); + handleSessInputChange(displayText); + if (draftSessionId) { + setDraft(displayText); + } + editor.focus(); + return displayText; + }, + [ + draftSessionId, + handleSessInputChange, + refs, + setDraft, + withProgrammaticInputMutation, + ] + ); + + const handleContentChange = useCallback(() => { + const restoreState = restoreStateRef.current; + const editor = refs.composerInputRef.current; + if (!restoreState || restoreState.sessionId !== draftSessionId || !editor) { + return; + } + + const currentSignature = snapshotSignature(editor.getSnapshot()); + if (currentSignature !== restoreState.polishedSignature) { + restoreStateRef.current = null; + setStatus("idle"); + } + }, [draftSessionId, refs, setStatus]); + + const toggle = useCallback(async () => { + const editor = refs.composerInputRef.current; + if (!editor || status === "polishing") return; + + const restoreState = restoreStateRef.current; + if (status === "polished" && restoreState?.sessionId === draftSessionId) { + applyComposerContent(restoreState.originalSnapshot); + reset(); + return; + } + + const originalSnapshot = editor.getSnapshot(); + if (!snapshotHasPolishableText(originalSnapshot)) { + Message.info("请输入需要润色的文字"); + return; + } + + const originalSignature = snapshotSignature(originalSnapshot); + const polishInput = snapshotToPolishText(originalSnapshot); + const requestSessionId = draftSessionId; + setStatus("polishing"); + + try { + if (!isAvailable) { + reset(); + Message.warning("MiniCPM 常驻管家的输入润色已关闭"); + return; + } + + const result = await requestPromptPolish(polishInput.text, { + accountId: housekeeper.resolvedAccountId ?? undefined, + model: housekeeper.resolvedModel, + }); + if (draftSessionIdRef.current !== requestSessionId) { + setState({ sessionId: requestSessionId, status: "idle" }); + return; + } + + const currentSignature = snapshotSignature(editor.getSnapshot()); + if (currentSignature !== originalSignature) { + reset(); + Message.info("内容已变化,已取消本次润色"); + return; + } + + const nextContent = + polishInput.pills.length > 0 + ? polishedTextToSnapshot(result.polishedText, polishInput.pills) + : result.polishedText.trim(); + if (!nextContent) { + reset(); + Message.warning("润色结果未保留引用内容,已保留原文"); + return; + } + + applyComposerContent(nextContent); + restoreStateRef.current = { + sessionId: requestSessionId, + originalSnapshot, + polishedSignature: snapshotSignature(editor.getSnapshot()), + }; + setStatus("polished"); + } catch (error) { + reset(); + Message.error(`润色失败:${formatPromptPolishError(error)}`); + } + }, [ + applyComposerContent, + draftSessionId, + isAvailable, + housekeeper.resolvedAccountId, + housekeeper.resolvedModel, + refs, + reset, + setStatus, + status, + ]); + + return useMemo( + () => ({ + status, + isAvailable, + isPolishing: status === "polishing", + isPolished: status === "polished", + toggle, + reset, + handleContentChange, + }), + [handleContentChange, isAvailable, reset, status, toggle] + ); +} diff --git a/src/engines/ChatPanel/hooks/useInputArea/useSlashCommand.ts b/src/engines/ChatPanel/hooks/useInputArea/useSlashCommand.ts index aaeafa134..6b16889fd 100644 --- a/src/engines/ChatPanel/hooks/useInputArea/useSlashCommand.ts +++ b/src/engines/ChatPanel/hooks/useInputArea/useSlashCommand.ts @@ -6,25 +6,31 @@ * built-in slash actions in a filterable dropdown. */ import { useAtomValue, useSetAtom } from "jotai"; -import { type RefObject, useCallback, useRef } from "react"; +import { type RefObject, useCallback, useMemo, useRef } from "react"; +import { useTranslation } from "react-i18next"; import type { ComposerInputRef } from "@src/components/ComposerInput"; import type { AgentExecMode } from "@src/config/sessionCreatorConfig"; import { buildMcpToolCommand } from "@src/engines/ChatPanel/InputArea/components/SlashCommandPortal/slashItemUtils"; -import { useSessionId } from "@src/engines/SessionCore/hooks/session/useSessionId"; +import { buildAddressCommentsPillPath } from "@src/features/Org2Cloud/addressCommentsSlashToken"; +import { + ADDRESS_COMMENTS_SLASH_SOURCE, + type AddressCommentsThreadOption, + useAddressCommentsSlashCommand, +} from "@src/features/Org2Cloud/useAddressCommentsSlashCommand"; import { useSessionExecModeField } from "@src/hooks/session/useSessionPatch"; import { creatorDefaultExecModeAtom } from "@src/store/session/creatorDefaultExecModeAtom"; -import type { SlashItem } from "@src/types/extensions"; +import { SLASH_ACTIONS, type SlashItem } from "@src/types/extensions"; import { useSlashItemsCache } from "./useSlashItemsCache"; -const BUILTIN_SLASH_ITEMS: SlashItem[] = []; - interface UseSlashCommandOptions { composerInputRef: RefObject; setShowSlashMenu: (show: boolean) => void; setSlashQuery: (query: string) => void; workspacePaths?: string[]; + /** The session already resolved by the owning InputArea. */ + sessionId?: string; /** * When `true`, `/mode` always reads + writes `creatorDefaultExecModeAtom` * even if there is an active session in the route. Set by callers that @@ -36,6 +42,11 @@ interface UseSlashCommandOptions { creatorDefaultMode?: boolean; } +export interface AddressCommentsFlyoutData { + threads: AddressCommentsThreadOption[]; + onConfirm: (selectedHeadIds: string[]) => void; +} + export interface SlashCommandHandlers { handleSlashCommand: (query: string) => void; handleSlashCommandClose: () => void; @@ -51,6 +62,7 @@ export interface SlashCommandHandlers { * "/" menu must stay closed. */ prefetchItems: (query: string) => void; + addressCommentsFlyout?: AddressCommentsFlyoutData; } export function useSlashCommand( @@ -61,17 +73,16 @@ export function useSlashCommand( setShowSlashMenu, setSlashQuery, workspacePaths, + sessionId, creatorDefaultMode: forceCreatorDefault = false, } = options; // Mode source-of-truth follows the session: when the slash command is // typed inside a live chat the `/` mode picker reads + writes the - // session row. The SessionCreator path explicitly opts out via - // `creatorDefaultMode: true` because `useSessionId()` would otherwise - // resolve to the previously-active session — which is NOT the - // session being configured in the creator — and a `/mode` pick - // there would silently rewrite the background session's pill. - const { sessionId } = useSessionId(); + // session row. Reuse the owning InputArea's already-resolved session id: + // resolving it again here without the InputArea's prop/session-scope can + // point slash actions at a stale background session after panel changes. + // The SessionCreator path explicitly opts out via `creatorDefaultMode`. const isInSession = !forceCreatorDefault && Boolean(sessionId); const creatorDefaultMode = useAtomValue(creatorDefaultExecModeAtom); const setCreatorDefaultMode = useSetAtom(creatorDefaultExecModeAtom); @@ -93,12 +104,32 @@ export function useSlashCommand( const queryRef = useRef(""); + const { t } = useTranslation("sessions"); + const { t: tNav } = useTranslation("navigation"); + const addressComments = useAddressCommentsSlashCommand( + isInSession ? sessionId : null + ); + const addressCommentsItem = addressComments.item; + const builtinSlashItems = useMemo( + () => [ + { + name: SLASH_ACTIONS.COMPACT, + description: t("input.compactCommandDescription"), + category: "action", + source: "builtin", + acceptsArgs: true, + }, + ...(addressCommentsItem ? [addressCommentsItem] : []), + ], + [t, addressCommentsItem] + ); + const { filteredItems, loading: slashLoading, prefetch, } = useSlashItemsCache({ - builtinItems: BUILTIN_SLASH_ITEMS, + builtinItems: builtinSlashItems, workspacePaths, }); @@ -126,10 +157,45 @@ export function useSlashCommand( queryRef.current = ""; }, [setShowSlashMenu, setSlashQuery]); + const addressThreads = addressComments.threads; + const insertAddressCommentsPill = useCallback( + (selectedHeadIds: string[]) => { + if (!composerInputRef.current) return; + const count = + selectedHeadIds.length > 0 + ? selectedHeadIds.length + : addressThreads.length; + composerInputRef.current.insertFilePill( + buildAddressCommentsPillPath(selectedHeadIds), + false, + "skill", + tNav("cloud.comments.addressPill", { count }) + ); + composerInputRef.current.focus(); + setShowSlashMenu(false); + setSlashQuery(""); + queryRef.current = ""; + }, + [composerInputRef, addressThreads, tNav, setShowSlashMenu, setSlashQuery] + ); + + const addressCommentsFlyout = useMemo( + () => + addressComments.available && addressThreads.length > 0 + ? { threads: addressThreads, onConfirm: insertAddressCommentsPill } + : undefined, + [addressComments.available, addressThreads, insertAddressCommentsPill] + ); + const handleSlashSelect = useCallback( (item: SlashItem) => { if (!composerInputRef.current) return; + if (item.source === ADDRESS_COMMENTS_SLASH_SOURCE) { + insertAddressCommentsPill([]); + return; + } + if (item.category === "skill") { const skillToken = `/${item.skillName ?? item.name}`; composerInputRef.current.insertFilePill( @@ -156,6 +222,24 @@ export function useSlashCommand( return; } + // The compact command renders as a pill (like skills) so the token + // reads as one unit with the focus text typed after it. The submit + // interceptor recognizes both the pill serialization and plain + // "/compact" text (parseCompactSlashCommand). + if (item.category === "action" && item.name === SLASH_ACTIONS.COMPACT) { + composerInputRef.current.insertFilePill( + `/${SLASH_ACTIONS.COMPACT}`, + false, + "skill", + SLASH_ACTIONS.COMPACT + ); + composerInputRef.current.focus(); + setShowSlashMenu(false); + setSlashQuery(""); + queryRef.current = ""; + return; + } + composerInputRef.current.setContent(`/${item.name} `); composerInputRef.current.focus(); @@ -163,7 +247,12 @@ export function useSlashCommand( setSlashQuery(""); queryRef.current = ""; }, - [composerInputRef, setShowSlashMenu, setSlashQuery] + [ + composerInputRef, + setShowSlashMenu, + setSlashQuery, + insertAddressCommentsPill, + ] ); const handleSlashAppendSelect = useCallback( @@ -213,5 +302,6 @@ export function useSlashCommand( filteredItems, slashLoading, prefetchItems, + addressCommentsFlyout, }; } diff --git a/src/engines/ChatPanel/hooks/useInputArea/useSlashItemsCache.ts b/src/engines/ChatPanel/hooks/useInputArea/useSlashItemsCache.ts index a23e5877a..bafd2788d 100644 --- a/src/engines/ChatPanel/hooks/useInputArea/useSlashItemsCache.ts +++ b/src/engines/ChatPanel/hooks/useInputArea/useSlashItemsCache.ts @@ -12,10 +12,12 @@ * - The hook maintains a warm in-memory cache via `itemsCacheRef`. * - `prefetch(query)` shows cached items immediately, then fires a fresh * fetch in the background and updates `filteredItems`. - * - `fetchFresh()` always hits the backend; the caller can await it. + * - `fetchFresh()` refreshes the list (bounded by the shared skills scanner's + * TTL + coalescing; pass `{ force: true }` to bypass the TTL). Awaitable. + * - Skill scans are lazy: mounting the hook does NOT scan — the backend + * `skills_list` fires only on `prefetch`/`fetchFresh`. * - A `cancelledRef` prevents setState after unmount. */ -import { invoke } from "@tauri-apps/api/core"; import { useSetAtom } from "jotai"; import { useCallback, useEffect, useRef, useState } from "react"; @@ -26,6 +28,7 @@ import { } from "@src/engines/ChatPanel/InputArea/components/SlashCommandPortal/slashItemUtils"; import { createLogger } from "@src/hooks/logger"; import { mergeInstalledSkills } from "@src/hooks/skills/installedSkillsMerge"; +import { scanInstalledSkills } from "@src/hooks/skills/installedSkillsScan"; import { installedSkillsAtom } from "@src/store/skills/installedSkillsAtom"; import { type InstalledSkill, type SlashItem } from "@src/types/extensions"; @@ -115,8 +118,12 @@ export interface UseSlashItemsCacheReturn { * backend fetch and update `filteredItems` when it resolves. */ prefetch: (query: string) => void; - /** Fire a fresh backend fetch unconditionally, update the full item list when it changed, and return it. */ - fetchFresh: () => Promise; + /** + * Fetch the full item list and update it when it changed. Bounded by the + * shared scanner's TTL + in-flight coalescing; pass `{ force: true }` to + * bypass the TTL (e.g. an explicit "…" panel open). + */ + fetchFresh: (options?: { force?: boolean }) => Promise; } export function useSlashItemsCache( @@ -138,108 +145,103 @@ export function useSlashItemsCache( const builtinItemsRef = useRef(builtinItems); builtinItemsRef.current = builtinItems; - const doFetch = useCallback(async (): Promise => { - setLoading(true); - try { - const scopePaths = workspacePathsKey ? workspacePathsKey.split("\0") : []; - const skillListTasks = [ - invoke("skills_list", { workspacePath: null }), - ...scopePaths.map((path) => - invoke("skills_list", { workspacePath: path }) - ), - ]; - const [skillResults, mcpServers] = await Promise.all([ - Promise.allSettled(skillListTasks), - rpc.mcp.listServers({}).catch((err) => { - logger.warn("Failed to list MCP servers for slash menu:", err); - return []; - }), - ]); + const doFetch = useCallback( + async (force = false): Promise => { + setLoading(true); + try { + const scopePaths = workspacePathsKey + ? workspacePathsKey.split("\0") + : []; + // Skills are scanned through the bounded shared scanner (coalesces + // concurrent callers, reuses a recent result within the TTL) so opening + // the menu/panel repeatedly can never hammer the backend `skills_list`. + const [rawSkills, mcpServers] = await Promise.all([ + scanInstalledSkills(scopePaths, { force }), + rpc.mcp.listServers({}).catch((err) => { + logger.warn("Failed to list MCP servers for slash menu:", err); + return []; + }), + ]); - const rawSkills = mergeInstalledSkills( - skillResults.flatMap((result) => { - if (result.status === "fulfilled") return [result.value]; - logger.warn("Failed to list skills for slash menu:", result.reason); - return []; - }) - ); - const workspaceSkillRoots = scopePaths.map(normalizeWorkspacePath); - logger.rateLimited("slash-skills-scan", 5_000, "slash skills fetched", { - workspacePaths: workspaceSkillRoots, - skillCount: rawSkills.length, - workspaceSkillCount: rawSkills.filter((skill) => - isWorkspaceSkill(skill, workspaceSkillRoots) - ).length, - skillPaths: rawSkills.map((skill) => skill.path), - }); + const workspaceSkillRoots = scopePaths.map(normalizeWorkspacePath); + logger.rateLimited("slash-skills-scan", 5_000, "slash skills fetched", { + workspacePaths: workspaceSkillRoots, + skillCount: rawSkills.length, + workspaceSkillCount: rawSkills.filter((skill) => + isWorkspaceSkill(skill, workspaceSkillRoots) + ).length, + skillPaths: rawSkills.map((skill) => skill.path), + }); - if (rawSkills.length > 0) { - setInstalledSkills((current) => - mergeInstalledSkills([current, rawSkills]) - ); - } + if (rawSkills.length > 0) { + setInstalledSkills((current) => + mergeInstalledSkills([current, rawSkills]) + ); + } - const skillItems: SlashItem[] = rawSkills - .filter((s) => s.enabled) - .map((s) => ({ - name: s.name, - skillName: s.name, - skillPath: s.path, - description: normalizeSkillDescription(s), - category: "skill" as const, - source: resolveSkillGroup(s), - acceptsArgs: false, - skillScope: isWorkspaceSkill(s, workspaceSkillRoots) - ? "workspace" - : "user", - })); + const skillItems: SlashItem[] = rawSkills + .filter((s) => s.enabled) + .map((s) => ({ + name: s.name, + skillName: s.name, + skillPath: s.path, + description: normalizeSkillDescription(s), + category: "skill" as const, + source: resolveSkillGroup(s), + acceptsArgs: false, + skillScope: isWorkspaceSkill(s, workspaceSkillRoots) + ? "workspace" + : "user", + })); - const connectedServers = mcpServers.filter( - (srv) => srv.status === "connected" && !srv.disabled - ); + const connectedServers = mcpServers.filter( + (srv) => srv.status === "connected" && !srv.disabled + ); - const toolItems: SlashItem[] = ( - await Promise.all( - connectedServers.map((srv) => - rpc.mcp.listServerTools({ serverName: srv.name }).then( - (tools) => - tools.map((tool) => ({ - name: tool.name, - description: tool.description, - category: "tool" as const, - source: srv.name, - acceptsArgs: true, - serverName: srv.name, - })), - (err) => { - logger.warn( - `Failed to list tools for MCP server "${srv.name}":`, - err - ); - return [] as SlashItem[]; - } + const toolItems: SlashItem[] = ( + await Promise.all( + connectedServers.map((srv) => + rpc.mcp.listServerTools({ serverName: srv.name }).then( + (tools) => + tools.map((tool) => ({ + name: tool.name, + description: tool.description, + category: "tool" as const, + source: srv.name, + acceptsArgs: true, + serverName: srv.name, + })), + (err) => { + logger.warn( + `Failed to list tools for MCP server "${srv.name}":`, + err + ); + return [] as SlashItem[]; + } + ) ) ) - ) - ).flat(); + ).flat(); - const assembled: SlashItem[] = [ - ...builtinItemsRef.current, - ...skillItems, - ...toolItems, - ]; + const assembled: SlashItem[] = [ + ...builtinItemsRef.current, + ...skillItems, + ...toolItems, + ]; - setScopedSlashItemsCache(workspacePathsKey, assembled); - if (!cancelledRef.current) { - itemsCacheRef.current = assembled; - } - return assembled; - } finally { - if (!cancelledRef.current) { - setLoading(false); + setScopedSlashItemsCache(workspacePathsKey, assembled); + if (!cancelledRef.current) { + itemsCacheRef.current = assembled; + } + return assembled; + } finally { + if (!cancelledRef.current) { + setLoading(false); + } } - } - }, [setInstalledSkills, workspacePathsKey]); + }, + [setInstalledSkills, workspacePathsKey] + ); const prefetch = useCallback( (_query: string) => { @@ -262,37 +264,34 @@ export function useSlashItemsCache( [doFetch, workspacePathsKey] ); - const fetchFresh = useCallback(async (): Promise => { - const currentItems = itemsCacheRef.current; - const items = await doFetch(); - if (!cancelledRef.current && !slashItemsEqual(currentItems, items)) { - setFilteredItems(items); - } - return items; - }, [doFetch]); + const fetchFresh = useCallback( + async (options?: { force?: boolean }): Promise => { + const currentItems = itemsCacheRef.current; + const items = await doFetch(options?.force ?? false); + if (!cancelledRef.current && !slashItemsEqual(currentItems, items)) { + setFilteredItems(items); + } + return items; + }, + [doFetch] + ); - // Warm the cache and refresh when the active repo/workspace scope changes. + // Lazy warm-up: when the active repo/workspace scope changes, only paint the + // cached items for that scope (instant, no IPC). The actual backend scan is + // deferred until the user opens the "/" menu (`prefetch`) or the "…" panel + // (`fetchFresh`), or a pinned skill needs resolving — so simply mounting the + // chat input no longer triggers a `skills_list` scan. useEffect(() => { cancelledRef.current = false; - const currentFetchSeq = fetchSeqRef.current + 1; - fetchSeqRef.current = currentFetchSeq; const cachedItems = slashItemsCacheByScope.get(workspacePathsKey) ?? []; if (cachedItems.length > 0) { itemsCacheRef.current = cachedItems; setFilteredItems(cachedItems); } - doFetch().then((items) => { - if (cancelledRef.current || fetchSeqRef.current !== currentFetchSeq) { - return; - } - if (cachedItems.length === 0 || !slashItemsEqual(cachedItems, items)) { - setFilteredItems(items); - } - }); return () => { cancelledRef.current = true; }; - }, [doFetch, workspacePathsKey]); + }, [workspacePathsKey]); return { filteredItems, loading, prefetch, fetchFresh }; } diff --git a/src/engines/ChatPanel/hooks/useInputArea/useSubmitMessage.ts b/src/engines/ChatPanel/hooks/useInputArea/useSubmitMessage.ts index e15023c0b..e21250beb 100644 --- a/src/engines/ChatPanel/hooks/useInputArea/useSubmitMessage.ts +++ b/src/engines/ChatPanel/hooks/useInputArea/useSubmitMessage.ts @@ -22,11 +22,20 @@ import { rejectQuestion, respondQuestion } from "@src/api/tauri/agent"; import Message from "@src/components/Message"; import { extractQuestionBatch } from "@src/engines/ChatPanel/InputArea/AskQuestionCard/extractQuestionBatch"; import { chatEventsAtom } from "@src/engines/SessionCore"; +import { parseAddressCommentsSlashCommand } from "@src/features/Org2Cloud/addressCommentsSlashToken"; +import { useAddressCommentsSlashCommand } from "@src/features/Org2Cloud/useAddressCommentsSlashCommand"; import { createLogger } from "@src/hooks/logger"; +import { useSecretScanGuard } from "@src/hooks/security/useSecretScanGuard"; +import { sessionByIdAtom } from "@src/store/session"; import type { ChatImageAttachment } from "@src/store/ui/chatImageAtom"; import { wpReadOnlyAtom } from "@src/store/ui/chatPanelAtom"; import { clearImageDraft } from "../../InputArea/utils/imageDraftCache"; +import { + manualCompactInFlightSessionAtom, + parseCompactSlashCommand, + useManualCompact, +} from "../useManualCompact"; import { resolveMcpSlashCommand } from "./mcpSlashCommand"; import type { CiteCodeSnapshot, @@ -97,6 +106,12 @@ export interface UseSubmitMessageOptions { // Hook // ============================================================================ +function lastSerializedPillLabel(rawLabel: string): string { + const trimmed = rawLabel.trim(); + const lastSpaceIdx = trimmed.search(/\s[^\s]*$/); + return lastSpaceIdx >= 0 ? trimmed.slice(lastSpaceIdx + 1).trim() : trimmed; +} + export function useSubmitMessage({ refs, draftSessionId, @@ -113,18 +128,39 @@ export function useSubmitMessage({ const store = useStore(); const wpReadOnly = useAtomValue(wpReadOnlyAtom); const submitInFlightKeyRef = useRef(null); + const { runManualCompact } = useManualCompact(); + const guardAgainstSecrets = useSecretScanGuard(); + const addressComments = useAddressCommentsSlashCommand( + draftSessionId || null + ); return useCallback( async (options: SubmitMessageOptions = {}) => { - if (submitDisabled) return; - - if (wpReadOnly) { + // Imported teammate replays are intentionally read-only in the event + // store, but their composer owns an onSubmitOverride that performs + // fork-before-send. Let that coordinator inspect the submission before + // applying the ordinary read-only guard; otherwise the generic + // "No active session" toast makes the fork flow unreachable. + if (wpReadOnly && !onSubmitOverride) { Message.warning(t("chat.noActiveSession")); return; } if (!refs.composerInputRef.current) return; + // ── Compaction gate ────────────────────────────────────────────────── + // While this session's durable transcript is being rewritten by a + // manual compaction, hold new messages instead of dispatching them. + // (The backend scheduler serializes them anyway; this keeps the UX + // honest — the user sees why nothing is happening.) + if ( + draftSessionId && + store.get(manualCompactInFlightSessionAtom) === draftSessionId + ) { + Message.info(t("common:contextInfo.manualCompactInProgress")); + return; + } + const liveDisplayText = refs.composerInputRef.current.getTextWithPills(); let displayText = liveDisplayText.trim().length > 0 @@ -135,6 +171,53 @@ export function useSubmitMessage({ if (!hasText && !hasAttachedImages) return; + // ── /compact slash command ─────────────────────────────────────────── + // `/compact [instructions]` runs a manual context compaction instead + // of dispatching a message (Claude Code parity). Only a pure text + // command qualifies — attached images mean the user is sending real + // content that happens to start with "/compact". + if (hasText && !hasAttachedImages) { + const compactCommand = parseCompactSlashCommand(displayText); + if (compactCommand) { + refs.composerInputRef.current.clear(); + void flushDraft("").catch((err: unknown) => { + log.warn("[useSubmitMessage] flushDraft(compact) failed:", err); + }); + void runManualCompact( + draftSessionId || null, + compactCommand.instructions + ); + return; + } + + const addressDraft = parseAddressCommentsSlashCommand(displayText); + if (addressDraft) { + refs.composerInputRef.current.clear(); + void flushDraft("").catch((err: unknown) => { + log.warn("[useSubmitMessage] flushDraft(address) failed:", err); + }); + addressComments.run({ + selectedHeadIds: addressDraft.selectedHeadIds, + instruction: addressDraft.instruction, + }); + return; + } + } + + // A running session blocks ordinary sends, but not `/compact`: manual + // compaction is a maintenance job queued behind the active turn by the + // backend scheduler. Parse the command first so selecting its pill never + // becomes a silent no-op while the session is working. + if (submitDisabled) return; + + // ── Secret scan gate ───────────────────────────────────────────────── + // Warn before a typed API key / token / password enters the transcript + // and reaches the model. The user can still choose to send anyway. + if (hasText) { + const clearedSecretScan = await guardAgainstSecrets(displayText); + if (!clearedSecretScan) return; + } + // ── Question intercept ──────────────────────────────────────────────── // When the agent asked a question and the user typed a reply in the main // input, forward the typed text as the question answer before dispatching. @@ -196,7 +279,7 @@ export function useSubmitMessage({ // ── Session pill ID injection ───────────────────────────────────────── // Session pills carry only the session ID (no transcript). Extract them // from the serialized display text and append lightweight references. - const sessionPillPattern = /([^[\s]+)\s*\[session:([^\]]+)\]/g; + const sessionPillPattern = /([^\n[]+?)\s*\[session:([^\]]+)\]/g; const sessionRefs: string[] = []; let sessionMatch: RegExpExecArray | null; while ( @@ -204,7 +287,15 @@ export function useSubmitMessage({ hasSkillPills ? skillExpanded : displayText )) !== null ) { - sessionRefs.push(`[Session Reference: ${sessionMatch[2]}]`); + const referencedSessionId = sessionMatch[2]; + const referencedSession = store.get( + sessionByIdAtom(referencedSessionId) + ); + const fallbackLabel = lastSerializedPillLabel(sessionMatch[1]); + const label = referencedSession?.name?.trim() || fallbackLabel; + sessionRefs.push( + `[Session Reference: ${label} (${referencedSessionId})]` + ); } // ── Terminal/PR pill text collection ───────────────────────────────── @@ -422,6 +513,7 @@ export function useSubmitMessage({ [ wpReadOnly, store, + guardAgainstSecrets, handleSessChatSubmit, citeCode, refs, @@ -433,6 +525,8 @@ export function useSubmitMessage({ clearReplyTarget, onSubmitOverride, submitDisabled, + runManualCompact, + addressComments, ] ); } diff --git a/src/engines/ChatPanel/hooks/useManualCompact.ts b/src/engines/ChatPanel/hooks/useManualCompact.ts new file mode 100644 index 000000000..1802973e3 --- /dev/null +++ b/src/engines/ChatPanel/hooks/useManualCompact.ts @@ -0,0 +1,194 @@ +/** + * useManualCompact — shared manual context-compaction flow. + * + * Single owner of the "a compaction is in flight" state and the + * status-to-toast mapping, consumed by both entry points: + * - the Compact button in the context-info popover (ContextInfoButton) + * - the `/compact [instructions]` slash command (useSubmitMessage) + * + * The in-flight session id lives in a global atom so the composer can + * refuse to dispatch a message into a session whose durable transcript is + * being rewritten, regardless of which surface started the compaction. + */ +import { atom, useStore } from "jotai"; +import { useCallback } from "react"; +import { useTranslation } from "react-i18next"; + +import { manualCompactSession } from "@src/api/tauri/agent/session"; +import { Message } from "@src/components/Message"; +import { triggerSessionReloadAtom } from "@src/engines/SessionCore"; +import { eventStoreProxy } from "@src/engines/SessionCore/core/store/EventStoreProxy"; +import { compactBoundaryToSessionEvent } from "@src/engines/SessionCore/ingestion/agentMessageAdapters"; + +import { formatTokenCount } from "../InputArea/components/useContextUsageInfo"; + +/** Session id with a manual compaction in flight, or `null`. */ +export const manualCompactInFlightSessionAtom = atom(null); + +export interface CompactSlashCommand { + /** Optional focus text following `/compact`. */ + instructions?: string; +} + +/** + * Parse a `/compact [instructions]` message. Returns `null` when the text + * is not a compact command, so ordinary messages (including other slash + * commands) pass through untouched. Mirrors Claude Code's `/compact + * [instructions for summarization]` shape; the command is case-insensitive + * and everything after the token becomes the summarization focus. + */ +export function parseCompactSlashCommand( + text: string +): CompactSlashCommand | null { + const trimmed = text.trim(); + + // Pill form ("compact [skill:/compact]", see serializePillNode) counts + // anywhere in the draft — the pill only exists because the user picked + // the command, so surrounding text on either side is the summarization + // focus. The plain typed form stays start-anchored below (mid-sentence + // "/compact" is ordinary prose, mirroring Claude Code). + const pillMatch = /compact\s*\[skill:\/compact\]/i.exec(trimmed); + if (pillMatch) { + const instructions = ( + trimmed.slice(0, pillMatch.index) + + " " + + trimmed.slice(pillMatch.index + pillMatch[0].length) + ).trim(); + return instructions ? { instructions } : {}; + } + + const match = /^\/compact(?:\s+([\s\S]+))?$/i.exec(trimmed); + if (!match) return null; + const instructions = match[1]?.trim(); + return instructions ? { instructions } : {}; +} + +export interface UseManualCompactReturn { + /** + * Run a manual compaction for `sessionId`. Resolves `true` when the + * backend actually compacted (callers can clear their inputs), `false` + * for every informational/failure outcome (a toast has already been + * shown either way). + */ + runManualCompact: ( + sessionId: string | null | undefined, + instructions?: string + ) => Promise; +} + +export function useManualCompact(): UseManualCompactReturn { + const { t } = useTranslation(); + const store = useStore(); + + const runManualCompact = useCallback( + async ( + sessionId: string | null | undefined, + instructions?: string + ): Promise => { + if (store.get(manualCompactInFlightSessionAtom) !== null) { + Message.info(t("contextInfo.manualCompactInProgress")); + return false; + } + if (!sessionId) { + Message.info(t("contextInfo.manualCompactNoRuntime")); + return false; + } + + store.set(manualCompactInFlightSessionAtom, sessionId); + try { + const trimmed = instructions?.trim(); + const result = await manualCompactSession( + sessionId, + trimmed || undefined + ); + + switch (result.status) { + case "compacted": + // Append the boundary marker in place. The load path dedups on + // the row id, so the next full hydrate won't duplicate it. Only + // fall back to evict + reload (which flashes the whole history) + // when the backend didn't return the persisted row. + if (result.boundary) { + await eventStoreProxy.append( + [ + compactBoundaryToSessionEvent( + { + id: result.boundary.id, + sessionId, + role: "system", + content: result.boundary.content, + toolName: null, + toolCallId: null, + toolInput: null, + toolOutput: null, + model: null, + sequence: 0, + createdAt: result.boundary.createdAt, + images: null, + compactFromSequence: 0, + compactTokensBefore: result.tokensBefore ?? null, + compactTokensAfter: result.tokensAfter ?? null, + }, + sessionId + ), + ], + sessionId + ); + } else { + await eventStoreProxy.evictSession(sessionId); + store.set(triggerSessionReloadAtom, sessionId); + } + Message.success( + t("contextInfo.manualCompactSuccess", { + messagesBefore: result.messagesBefore ?? 0, + messagesAfter: result.messagesAfter ?? 0, + tokensBefore: formatTokenCount(result.tokensBefore ?? 0), + tokensAfter: formatTokenCount(result.tokensAfter ?? 0), + }), + { duration: 2600 } + ); + return true; + case "too_short": + Message.info(t("contextInfo.manualCompactTooShort")); + return false; + case "already_compact": + Message.info(t("contextInfo.manualCompactAlreadyCompact")); + return false; + case "busy": + Message.info(t("contextInfo.manualCompactBusy")); + return false; + case "no_runtime": + Message.info(t("contextInfo.manualCompactNoRuntime")); + return false; + case "channel_attached": + Message.info(t("contextInfo.manualCompactChannelAttached")); + return false; + case "failed": + default: + Message.error( + t("contextInfo.manualCompactFailed", { + message: result.message ?? t("common:errors.unknown"), + }), + { duration: 3200 } + ); + return false; + } + } catch (error) { + const message = + error instanceof Error ? error.message : String(error ?? ""); + Message.error( + t("contextInfo.manualCompactFailed", { + message: message || t("common:errors.unknown"), + }), + { duration: 3200 } + ); + return false; + } finally { + store.set(manualCompactInFlightSessionAtom, null); + } + }, + [store, t] + ); + + return { runManualCompact }; +} diff --git a/src/engines/ChatPanel/hooks/useProjectWorkItemHandlers.ts b/src/engines/ChatPanel/hooks/useProjectWorkItemHandlers.ts index 5bb640e90..c68a45010 100644 --- a/src/engines/ChatPanel/hooks/useProjectWorkItemHandlers.ts +++ b/src/engines/ChatPanel/hooks/useProjectWorkItemHandlers.ts @@ -11,6 +11,7 @@ import { CHAT_PANEL_CONTENT_MODE, CHAT_PANEL_CREATE_TARGET, type ChatPanelContentMode, + type ChatPanelCreateProjectContext, type ChatPanelCreateTarget, type ChatPanelSelectedProject, type ChatPanelSelectedWorkItem, @@ -21,6 +22,12 @@ type StateSetter = (value: T | ((previous: T) => T)) => void; interface UseProjectWorkItemHandlersOptions { bumpProjectListRefresh: (updater: (previous: number) => number) => void; + /** + * Org context of the create surface (NEW_WORK_ITEM navigation from an + * org hub). Used to label the created item's org — the create result + * only carries the org id. + */ + createProjectContext: ChatPanelCreateProjectContext | null; dispatchClearSession: () => void; handleNewSession: () => void; selectedProject: ChatPanelSelectedProject | null; @@ -39,6 +46,7 @@ interface UseProjectWorkItemHandlersOptions { export function useProjectWorkItemHandlers({ bumpProjectListRefresh, + createProjectContext, dispatchClearSession, handleNewSession, selectedProject, @@ -115,6 +123,16 @@ export function useProjectWorkItemHandlers({ projectId: result.item?.frontmatter.project ?? workItem.project?.id ?? "", projectName: workItem.project?.name ?? "", + // Standalone items keep their creating org: WorkItemPanelView's + // standalone writes are org-scoped and would otherwise re-home + // the row to personal-org. The org NAME comes from the surface + // context — without it the panel breadcrumb falls back to + // "My Personal Org" even though the row is org-scoped. + orgId: result.orgId, + orgName: + result.orgId && result.orgId === createProjectContext?.orgId + ? createProjectContext?.scopeBreadcrumbLabel + : undefined, workItem, }); if (!result.keepOpen) { @@ -128,6 +146,7 @@ export function useProjectWorkItemHandlers({ } }, [ + createProjectContext, dispatchClearSession, sessionCreatorAvailable, setActiveSessionId, diff --git a/src/engines/ChatPanel/hooks/useReplyQuestion.tsx b/src/engines/ChatPanel/hooks/useReplyQuestion.tsx index dc508d4ae..561131627 100644 --- a/src/engines/ChatPanel/hooks/useReplyQuestion.tsx +++ b/src/engines/ChatPanel/hooks/useReplyQuestion.tsx @@ -1,5 +1,5 @@ import { useSetAtom } from "jotai"; -import { throttle } from "lodash"; +import throttle from "lodash/throttle"; import { useMemo } from "react"; import { useTranslation } from "react-i18next"; import { useSearchParams } from "react-router-dom"; diff --git a/src/engines/ChatPanel/hooks/useStreamingHud.ts b/src/engines/ChatPanel/hooks/useStreamingHud.ts index 3b16195ee..475666ba4 100644 --- a/src/engines/ChatPanel/hooks/useStreamingHud.ts +++ b/src/engines/ChatPanel/hooks/useStreamingHud.ts @@ -42,7 +42,8 @@ export function useStreamingHud( const engineActive = useAtomValue(isSessionEngineActiveAtom); const deltaMap = useAtomValue(streamingDeltaContentAtom); - const deltaContent = sessionId ? (deltaMap.get(sessionId) ?? "") : ""; + const liveDelta = sessionId ? deltaMap.get(sessionId) : undefined; + const deltaContent = liveDelta?.content ?? ""; const producing = engineActive && !!sessionId; // A single state holding the open timing window: `{ startedAt, now }`. diff --git a/src/engines/ChatPanel/hooks/useViewportWidth.ts b/src/engines/ChatPanel/hooks/useViewportWidth.ts new file mode 100644 index 000000000..ea6d77e26 --- /dev/null +++ b/src/engines/ChatPanel/hooks/useViewportWidth.ts @@ -0,0 +1,18 @@ +import { useEffect, useState } from "react"; + +export function useViewportWidth(): number | undefined { + const [viewportWidth, setViewportWidth] = useState(() => + typeof window !== "undefined" ? window.innerWidth : undefined + ); + + useEffect(() => { + const handleResize = () => { + setViewportWidth(window.innerWidth); + }; + + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, []); + + return viewportWidth; +} diff --git a/src/engines/ChatPanel/hooks/useWorkspaceChat/useMessageDispatch.ts b/src/engines/ChatPanel/hooks/useWorkspaceChat/useMessageDispatch.ts index f04b69f53..48412c4d5 100644 --- a/src/engines/ChatPanel/hooks/useWorkspaceChat/useMessageDispatch.ts +++ b/src/engines/ChatPanel/hooks/useWorkspaceChat/useMessageDispatch.ts @@ -85,7 +85,8 @@ export function useMessageDispatch(options: UseMessageDispatchOptions) { modelSelectionOverride?: LastModelSelection, displayText?: string, clientMessageId?: string, - turnIntentId?: string + turnIntentId?: string, + reservedDispatchGeneration?: number ): Promise => { // Read directly from the store at call time to avoid stale-closure // race: if the user changes the mode pill and immediately sends a @@ -112,7 +113,8 @@ export function useMessageDispatch(options: UseMessageDispatchOptions) { // Synchronous turn reserve: every dispatch funnels through here, so the // FSM observes the session as busy before the first await. A concurrent // submit therefore queues instead of double-dispatching. - const dispatchGeneration = beginTurnDispatch(sessionId); + const dispatchGeneration = + reservedDispatchGeneration ?? beginTurnDispatch(sessionId); beginOptimisticTurn(sessionId); diff --git a/src/engines/ChatPanel/hooks/useWorkspaceChat/useSessionActions.ts b/src/engines/ChatPanel/hooks/useWorkspaceChat/useSessionActions.ts index e5f9b5516..5b5bbfca9 100644 --- a/src/engines/ChatPanel/hooks/useWorkspaceChat/useSessionActions.ts +++ b/src/engines/ChatPanel/hooks/useWorkspaceChat/useSessionActions.ts @@ -96,12 +96,19 @@ export function shouldRestoreStoppedUserMessage(options: { }): boolean { if (!options.message) return false; - const userEventIndex = options.events.findLastIndex( - (event) => + const { message } = options; + let userEventIndex = -1; + for (let index = options.events.length - 1; index >= 0; index -= 1) { + const event = options.events[index]; + if ( event.sessionId === options.sessionId && event.source === "user" && - event.displayText === options.message?.displayContent - ); + event.displayText === message.displayContent + ) { + userEventIndex = index; + break; + } + } if (userEventIndex === -1) return true; return !options.events @@ -185,27 +192,29 @@ export function useSessionActions(options: UseSessionActionsOptions) { pendingImages: pendingSyntheticEvent?.result?.images, }); + const restorableMessage = currentUserMessage; if ( + restorableMessage && shouldRestoreStoppedUserMessage({ events: store.get(eventsAtom), sessionId, - message: currentUserMessage, + message: restorableMessage, }) ) { setRestoreToInput({ sessionId, - displayContent: currentUserMessage.displayContent, - imageDataUrls: currentUserMessage.imageDataUrls, + displayContent: restorableMessage.displayContent, + imageDataUrls: restorableMessage.imageDataUrls, }); markRestoredStopDraft({ sessionId, - displayContent: currentUserMessage.displayContent, - imageDataUrls: currentUserMessage.imageDataUrls, + displayContent: restorableMessage.displayContent, + imageDataUrls: restorableMessage.imageDataUrls, }); suppressRestoredStopSubmit({ sessionId, - displayContent: currentUserMessage.displayContent, - imageDataUrls: currentUserMessage.imageDataUrls, + displayContent: restorableMessage.displayContent, + imageDataUrls: restorableMessage.imageDataUrls, }); } diff --git a/src/engines/ChatPanel/hooks/useWorkspaceChat/useUserIntentSubmit.ts b/src/engines/ChatPanel/hooks/useWorkspaceChat/useUserIntentSubmit.ts new file mode 100644 index 000000000..9d203abbe --- /dev/null +++ b/src/engines/ChatPanel/hooks/useWorkspaceChat/useUserIntentSubmit.ts @@ -0,0 +1,277 @@ +/** + * useUserIntentSubmit + * + * Single frontend entry point for user-intent turns: composer submits, + * interactive cards, and edit-resubmit flows all append the synthetic user + * event and dispatch through this hook so turnIntentId, queueing, and FSM + * reservation stay aligned. + */ +import { useAtomValue, useSetAtom, useStore } from "jotai"; +import { useCallback, useEffect } from "react"; + +import type { AgentExecMode } from "@src/config/sessionCreatorConfig"; +import { + beginOptimisticTurn, + failOptimisticTurn, +} from "@src/engines/SessionCore/control/optimisticTurnStatus"; +import { + beginTurnDispatch, + getTurnPhase, + markTurnTerminal, +} from "@src/engines/SessionCore/control/turnLifecycle"; +import { mintTurnIntentId } from "@src/engines/SessionCore/sync/adapters/shared/eventFactories"; +import { + type SessionRuntimeStatusSource, + isSessionActiveAtom, + lastUserMessageAtom, + userInitiatedCancelAtom, +} from "@src/store/session/cliSessionStatusAtom"; +import { creatorDefaultExecModeAtom } from "@src/store/session/creatorDefaultExecModeAtom"; +import { creatorDefaultModelSelectionAtom } from "@src/store/session/creatorDefaultModelAtom"; +import { sessionMapAtom } from "@src/store/session/sessionAtom"; +import { + enqueueMessageAtom, + messageQueueAtom, + queueFlushRequestAtom, +} from "@src/store/ui/messageQueueAtom"; +import { selectionFromSession } from "@src/util/session/selectionFromSession"; + +import { + consumeRestoredStopDraft, + consumeRestoredStopSubmitSuppression, +} from "./stopSubmitGuard"; +import { useMessageDispatch } from "./useMessageDispatch"; + +const sharedSubmitGuard = { current: false }; +const sharedSubmitPayload = { current: null as string | null }; + +function buildSubmitPayloadKey( + sessionId: string, + displayContent: string, + agentContent?: string, + imageDataUrls?: string[] +): string { + return JSON.stringify({ + sessionId, + displayContent, + agentContent: agentContent ?? null, + imageDataUrls: imageDataUrls ?? [], + }); +} + +function stableSubmitHash(value: string): string { + let hash = 0x811c9dc5; + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index); + hash = Math.imul(hash, 0x01000193); + } + return (hash >>> 0).toString(36); +} + +export interface SubmitUserIntentOptions { + sessionId?: string | null; + displayContent: string; + agentContent?: string; + imageDataUrls?: string[]; + source?: SessionRuntimeStatusSource; + applyStopSubmitGuards?: boolean; + dedupeDirectSubmit?: boolean; + clearUserInitiatedCancelOnQueue?: boolean; + swallowErrorAfterUserEventAppend?: boolean; + onQueued?: () => void; + onBeforeDirectDispatch?: () => void; +} + +interface UseUserIntentSubmitOptions { + getSessionId: () => string | null; +} + +export function useUserIntentSubmit({ + getSessionId, +}: UseUserIntentSubmitOptions) { + const store = useStore(); + const isSessionActive = useAtomValue(isSessionActiveAtom); + const enqueueMessage = useSetAtom(enqueueMessageAtom); + const setQueueFlushRequest = useSetAtom(queueFlushRequestAtom); + const setLastUserMessage = useSetAtom(lastUserMessageAtom); + const setUserInitiatedCancel = useSetAtom(userInitiatedCancelAtom); + const { addUserMessage, dispatchMessageBySessionType } = useMessageDispatch({ + getSessionId, + }); + + useEffect(() => { + if (!isSessionActive) { + sharedSubmitGuard.current = false; + sharedSubmitPayload.current = null; + } + }, [isSessionActive]); + + return useCallback( + async ({ + sessionId: explicitSessionId, + displayContent, + agentContent, + imageDataUrls, + source = "dispatch", + applyStopSubmitGuards = false, + dedupeDirectSubmit = false, + clearUserInitiatedCancelOnQueue = false, + swallowErrorAfterUserEventAppend = false, + onQueued, + onBeforeDirectDispatch, + }: SubmitUserIntentOptions): Promise => { + const sessionId = explicitSessionId ?? getSessionId(); + if (!sessionId) { + throw new Error("[useUserIntentSubmit] no active sessionId"); + } + + const contentForAgent = agentContent ?? displayContent; + const restoreImageDataUrls = + imageDataUrls && imageDataUrls.length > 0 ? imageDataUrls : undefined; + const submitPayloadKey = buildSubmitPayloadKey( + sessionId, + displayContent, + agentContent, + imageDataUrls + ); + const turnIntentId = mintTurnIntentId(); + + if ( + applyStopSubmitGuards && + consumeRestoredStopSubmitSuppression({ + sessionId, + displayContent, + imageDataUrls, + }) + ) { + return; + } + + const restoredStopDraftSubmit = applyStopSubmitGuards + ? consumeRestoredStopDraft({ + sessionId, + displayContent, + imageDataUrls, + }) + : false; + const explicitPostStopSubmit = + restoredStopDraftSubmit || store.get(userInitiatedCancelAtom); + + if ( + dedupeDirectSubmit && + sharedSubmitGuard.current && + sharedSubmitPayload.current === submitPayloadKey + ) { + return; + } + + const hasQueuedNaturalSibling = store + .get(messageQueueAtom) + .some( + (message) => + message.sessionId === sessionId && !message.requiresExplicitDispatch + ); + const shouldEnqueue = + explicitPostStopSubmit || + getTurnPhase(sessionId) !== "idle" || + hasQueuedNaturalSibling; + + if (shouldEnqueue) { + const session = store.get(sessionMapAtom).get(sessionId); + const creatorDefaultSelection = store.get( + creatorDefaultModelSelectionAtom + ); + const snapshotSelection = selectionFromSession( + session, + creatorDefaultSelection + ); + const snapshotMode: AgentExecMode = + (session?.agentExecMode as AgentExecMode | undefined) ?? + store.get(creatorDefaultExecModeAtom); + + if (clearUserInitiatedCancelOnQueue && explicitPostStopSubmit) { + setUserInitiatedCancel(false); + } + + enqueueMessage({ + id: `queued-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`, + turnIntentId, + sessionId, + content: contentForAgent, + displayContent, + imageDataUrls, + modelSelection: snapshotSelection ?? undefined, + agentExecMode: snapshotMode, + priority: explicitPostStopSubmit ? "now" : "next", + status: "queued", + createdAt: new Date().toISOString(), + }); + if (explicitPostStopSubmit) { + setQueueFlushRequest((requestId) => requestId + 1); + } + if (!explicitPostStopSubmit) { + beginOptimisticTurn(sessionId, "queue"); + } + onQueued?.(); + return; + } + + setLastUserMessage({ + sessionId, + displayContent, + imageDataUrls: restoreImageDataUrls, + }); + const dispatchGeneration = beginTurnDispatch(sessionId); + beginOptimisticTurn(sessionId, source); + if (dedupeDirectSubmit) { + sharedSubmitGuard.current = true; + sharedSubmitPayload.current = submitPayloadKey; + } + + let userEventAppended = false; + let dispatchStarted = false; + try { + onBeforeDirectDispatch?.(); + await addUserMessage(displayContent, imageDataUrls, turnIntentId); + userEventAppended = true; + const displayTextForDispatch = + contentForAgent !== displayContent ? displayContent : undefined; + dispatchStarted = true; + await dispatchMessageBySessionType( + sessionId, + contentForAgent, + imageDataUrls, + undefined, + displayTextForDispatch, + `direct:${sessionId}:${stableSubmitHash(submitPayloadKey)}`, + turnIntentId, + dispatchGeneration + ); + } catch (error) { + if (dedupeDirectSubmit) { + sharedSubmitGuard.current = false; + sharedSubmitPayload.current = null; + } + if (!dispatchStarted) { + failOptimisticTurn(sessionId, source); + markTurnTerminal(sessionId, "failed", { + generation: dispatchGeneration, + }); + } + if (!userEventAppended || !swallowErrorAfterUserEventAppend) { + throw error; + } + } + }, + [ + addUserMessage, + dispatchMessageBySessionType, + enqueueMessage, + getSessionId, + setLastUserMessage, + setQueueFlushRequest, + setUserInitiatedCancel, + store, + ] + ); +} diff --git a/src/engines/ChatPanel/hooks/useWorkspaceChat/useWorkspaceChat.ts b/src/engines/ChatPanel/hooks/useWorkspaceChat/useWorkspaceChat.ts index 70e86a701..f2c122f86 100644 --- a/src/engines/ChatPanel/hooks/useWorkspaceChat/useWorkspaceChat.ts +++ b/src/engines/ChatPanel/hooks/useWorkspaceChat/useWorkspaceChat.ts @@ -7,111 +7,30 @@ * @example * const { handleSessChatSubmit, loading } = useWorkspaceChat(); */ -import { useAtomValue, useSetAtom, useStore } from "jotai"; -import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { useAtomValue } from "jotai"; +import React, { useCallback, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { useSearchParams } from "react-router-dom"; import { isHostedFromSearchParams } from "@src/api/http/session/unified"; -import { enterAgentOrgSessionIntervention } from "@src/api/tauri/agent"; -import { isHostedKey } from "@src/api/tauri/session"; import Message from "@src/components/Message"; -import type { AgentExecMode } from "@src/config/sessionCreatorConfig"; -import { - beginOptimisticTurn, - failOptimisticTurn, -} from "@src/engines/SessionCore/control/optimisticTurnStatus"; -import { - beginTurnDispatch, - forceTurnIdle, - getTurnPhase, -} from "@src/engines/SessionCore/control/turnLifecycle"; import { sessionIdAtom } from "@src/engines/SessionCore/core/atoms"; import { useSessionId } from "@src/engines/SessionCore/hooks/session"; -import { mintTurnIntentId } from "@src/engines/SessionCore/sync/adapters/shared/eventFactories"; import { createLogger } from "@src/hooks/logger"; -import { - isSessionActiveAtom, - lastUserMessageAtom, - userInitiatedCancelAtom, -} from "@src/store/session/cliSessionStatusAtom"; -import { creatorDefaultExecModeAtom } from "@src/store/session/creatorDefaultExecModeAtom"; -import { - type LastModelSelection, - creatorDefaultModelSelectionAtom, -} from "@src/store/session/creatorDefaultModelAtom"; -import { sessionMapAtom } from "@src/store/session/sessionAtom"; +import { isSessionActiveAtom } from "@src/store/session/cliSessionStatusAtom"; import { activeSessionIdAtom, workstationActiveSessionIdAtom, } from "@src/store/session/viewAtom"; -import { - enqueueMessageAtom, - messageQueueAtom, - queueFlushRequestAtom, -} from "@src/store/ui/messageQueueAtom"; import { isAgentSession, isCliSession, } from "@src/util/session/sessionDispatch"; -import { - consumeRestoredStopDraft, - consumeRestoredStopSubmitSuppression, -} from "./stopSubmitGuard"; -import { useMessageDispatch } from "./useMessageDispatch"; import { useSessionActions } from "./useSessionActions"; +import { useUserIntentSubmit } from "./useUserIntentSubmit"; const log = createLogger("useWorkspaceChat"); - -/** - * Module-level submit guard shared across all useWorkspaceChat instances. - * - * WHY module-level (not per-instance ref or per-session Map): - * The chat panel can mount multiple useWorkspaceChat consumers simultaneously - * (InputArea + EditUserMessage each instantiate the hook independently). A - * per-instance ref would let both fire in the same React render batch, sending - * the same message twice. The module-level object acts as a cross-instance - * mutex for the *currently active* submit. - * - * WHY not keyed by sessionId: - * In the current layout there is at most one active submit in flight at any - * time — switching sessions clears the guard via the isWpGeneWorking effect - * (line ~129). A Map would be safer but is unnecessary - * complexity until multi-session parallel dispatch is a real requirement. - * - * IMPORTANT: guard.current is cleared asynchronously via a useEffect that - * watches isWpGeneWorking, NOT synchronously on submit. There is a 1-2 frame - * window after a fast session completes where a re-submit of the identical - * payload would be silently dropped. This is intentional — the dedup check - * (submitPayloadKey equality) is the safety valve, not the guard alone. - */ -const _sharedSubmitGuard = { current: false }; -const _sharedSubmitPayload = { current: null as string | null }; - -function buildSubmitPayloadKey( - sessionId: string, - displayContent: string, - agentContent?: string, - imageDataUrls?: string[] -): string { - return JSON.stringify({ - sessionId, - displayContent, - agentContent: agentContent ?? null, - imageDataUrls: imageDataUrls ?? [], - }); -} - -function stableSubmitHash(value: string): string { - let hash = 0x811c9dc5; - for (let index = 0; index < value.length; index += 1) { - hash ^= value.charCodeAt(index); - hash = Math.imul(hash, 0x01000193); - } - return (hash >>> 0).toString(36); -} - interface UseWorkspaceChatOptions { sessionId?: string; sessionScope?: "active" | "none"; @@ -121,7 +40,6 @@ const useWorkspaceChat = (options: UseWorkspaceChatOptions = {}) => { const { sessionId: propSessionId, sessionScope = "active" } = options; const { t } = useTranslation("sessions"); const [searchParams] = useSearchParams(); - const store = useStore(); const rawIsHosted = useMemo( () => isHostedFromSearchParams(searchParams), @@ -135,8 +53,6 @@ const useWorkspaceChat = (options: UseWorkspaceChatOptions = {}) => { // ============================================ const rawIsWpGeneWorking = useAtomValue(isSessionActiveAtom); const isWpGeneWorking = isSessionless ? false : rawIsWpGeneWorking; - const setUserInitiatedCancel = useSetAtom(userInitiatedCancelAtom); - const setLastUserMessage = useSetAtom(lastUserMessageAtom); // SessionCore engine-level session ID — always tracks the currently // synced session (set by loadSessionAtom inside useSessionSync). const coreSessionId = useAtomValue(sessionIdAtom); @@ -149,39 +65,12 @@ const useWorkspaceChat = (options: UseWorkspaceChatOptions = {}) => { workstationActiveSessionIdAtom ); - // ============================================ - // Queue Atoms - // ============================================ - const enqueueMessage = useSetAtom(enqueueMessageAtom); - const setQueueFlushRequest = useSetAtom(queueFlushRequestAtom); - // Per-session source-of-truth: when enqueuing we snapshot the model - // and exec-mode that the *session row* currently has, so a model or - // mode swap done while the queue is draining cannot retroactively - // change still-pending messages. The creator-default atoms are only - // used as a fallback for the (rare) case where the session row has - // no model/mode written yet — e.g. the very first message of a - // freshly created session before any pill interaction. - const sessionMap = useAtomValue(sessionMapAtom); - const creatorDefaultSelection = useAtomValue( - creatorDefaultModelSelectionAtom - ); - const creatorDefaultMode = useAtomValue(creatorDefaultExecModeAtom); - // ============================================ // Local State // ============================================ const [sessChatInput, setSessChatInput] = useState(""); const [loading, setLoading] = useState(false); - // Sync the shared submit guard with runtime status so it clears - // when the session finishes, regardless of which instance started it. - useEffect(() => { - if (!isWpGeneWorking) { - _sharedSubmitGuard.current = false; - _sharedSubmitPayload.current = null; - } - }, [isWpGeneWorking]); - // ============================================ // Session ID Helper // ============================================ @@ -207,9 +96,7 @@ const useWorkspaceChat = (options: UseWorkspaceChatOptions = {}) => { // ============================================ // Sub-hooks // ============================================ - const { addUserMessage, dispatchMessageBySessionType } = useMessageDispatch({ - getSessionId, - }); + const submitUserIntent = useUserIntentSubmit({ getSessionId }); const { resumeSession, interruptSession, stopSession } = useSessionActions({ getSessionId, @@ -236,218 +123,36 @@ const useWorkspaceChat = (options: UseWorkspaceChatOptions = {}) => { const finalInput = inputValue || sessChatInput; if (!finalInput.trim()) return; - const contentForAgent = agentContent || finalInput; - const restoreImageDataUrls = - imageDataUrls && imageDataUrls.length > 0 ? imageDataUrls : undefined; const sessionId = getSessionId(); if (!sessionId) { Message.error(t("errors.noSessionIdFound")); throw new Error("No session ID"); } - const submitPayloadKey = buildSubmitPayloadKey( - sessionId, - finalInput, - agentContent, - imageDataUrls - ); - const directClientMessageId = `direct:${sessionId}:${stableSubmitHash( - submitPayloadKey - )}`; - // Mint the canonical user-intent id once at the submit boundary. - // This is the same value that travels through the queue, the synthetic - // user event, and the wire call to `agent_send_message`. See - // `QueuedMessage.turnIntentId` for the full propagation contract. - const turnIntentId = mintTurnIntentId(); - if ( - consumeRestoredStopSubmitSuppression({ - sessionId, - displayContent: finalInput, - imageDataUrls, - }) - ) { - return; - } - // Re-submitting the exact draft a Stop restored is an explicit - // post-Stop dispatch; so is any submit while a stop episode is open. - const restoredStopDraftSubmit = consumeRestoredStopDraft({ - sessionId, - displayContent: finalInput, - imageDataUrls, - }); - const explicitPostStopSubmit = - store.get(userInitiatedCancelAtom) || restoredStopDraftSubmit; - - // Duplicate-submit guard: the exact payload of the in-flight direct - // dispatch is dropped until the runtime settles (double-click safety). - if ( - _sharedSubmitGuard.current && - _sharedSubmitPayload.current === submitPayloadKey - ) { - return; - } - - // ── submitOrEnqueue: THE single queue/direct decision ──────────────── - // Queue when the turn-lifecycle FSM says a turn is open (or closing), - // when a stop episode makes this an explicit post-Stop dispatch, or - // when older natural siblings are still queued (FIFO ordering). - const turnPhase = getTurnPhase(sessionId); - const hasQueuedNaturalSibling = store - .get(messageQueueAtom) - .some( - (message) => - message.sessionId === sessionId && !message.requiresExplicitDispatch - ); - const shouldEnqueue = - explicitPostStopSubmit || - turnPhase !== "idle" || - hasQueuedNaturalSibling; - - if (shouldEnqueue) { - setSessChatInput(""); - - // Build the snapshot from the session row, falling back to the - // creator-default for fields the row doesn't carry. Mirrors - // `selectionFromSession` in useMessageDispatch / useQueueDispatch - // so the enqueued shape matches what the dispatcher would have - // computed at send time — except this version is frozen on the - // QueuedMessage for the lifetime of that entry. - const session = sessionMap.get(sessionId); - const keySource = - session?.keySource ?? creatorDefaultSelection?.keySource; - const market = isHostedKey(keySource); - const snapshotSelection: LastModelSelection | undefined = session - ? { - ...creatorDefaultSelection, - keySource, - model: market - ? undefined - : (session.model ?? creatorDefaultSelection?.model), - listingModel: market - ? (session.model ?? creatorDefaultSelection?.listingModel) - : undefined, - selectedAccountId: - session.accountId ?? creatorDefaultSelection?.selectedAccountId, - cliAgentType: - session.cliAgentType ?? creatorDefaultSelection?.cliAgentType, - tier: session.tier ?? creatorDefaultSelection?.tier, - } - : (creatorDefaultSelection ?? undefined); - const snapshotMode: AgentExecMode = - (session?.agentExecMode as AgentExecMode | undefined) ?? - creatorDefaultMode; - - if (explicitPostStopSubmit) { - setUserInitiatedCancel(false); - } - - enqueueMessage({ - id: `queued-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`, - turnIntentId, - sessionId, - content: contentForAgent, - displayContent: finalInput, - imageDataUrls, - modelSelection: snapshotSelection, - agentExecMode: snapshotMode, - // Explicit post-Stop dispatches jump the queue and may interrupt; - // everything else is a natural FIFO follow-up. - priority: explicitPostStopSubmit ? "now" : "next", - status: "queued", - createdAt: new Date().toISOString(), - }); - if (explicitPostStopSubmit) { - setQueueFlushRequest((requestId) => requestId + 1); - } - // Light up the planning indicator immediately for the visible session. - // The enqueue path used to return without any optimistic status, so a - // message that was queued (turn FSM not idle) showed no "working" - // feedback until the queue drainer finally dispatched it — which is - // gated behind MIN_QUEUE_VISIBLE_MS + a backend re-check and is worst - // for image turns whose first stream event lands seconds later. The - // gate inside setSessionRuntimeStatusAtom drops this write for - // background sessions, and `!isPendingCancel` suppresses it during a - // Stop episode, so it is safe to call unconditionally here. - if (!explicitPostStopSubmit) { - beginOptimisticTurn(sessionId, "queue"); - } - return; - } - - // Capture the user-visible payload before any async append/dispatch work. - // Stop can be clicked immediately after the composer clears, before the - // optimistic EventStore append finishes, so cancel-restore needs this - // synchronous source of truth for text and images. - setLastUserMessage({ - sessionId, - displayContent: finalInput, - imageDataUrls: restoreImageDataUrls, - }); - - // Synchronously reserve the turn BEFORE any await: from this instant, - // every concurrent submit and the queue dispatcher observe this session - // as busy, so nothing can race a second direct dispatch. - beginTurnDispatch(sessionId); - - beginOptimisticTurn(sessionId); - setSessChatInput(""); setLoading(true); - _sharedSubmitGuard.current = true; - _sharedSubmitPayload.current = submitPayloadKey; - - let userEventAppended = false; try { - await addUserMessage(finalInput, imageDataUrls, turnIntentId); - userEventAppended = true; - void enterAgentOrgSessionIntervention(sessionId).catch((error) => { - log.warn("[useWorkspaceChat] intervention failed:", error); - }); - // Pass finalInput as displayText so the pill format is preserved in - // the persisted event. Only needed when the agent content differs - // (i.e. skill pills were expanded). - const displayTextForDispatch = - contentForAgent !== finalInput ? finalInput : undefined; - await dispatchMessageBySessionType( + await submitUserIntent({ sessionId, - contentForAgent, + displayContent: finalInput, + agentContent, imageDataUrls, - undefined, - displayTextForDispatch, - directClientMessageId, - turnIntentId - ); + source: "dispatch", + applyStopSubmitGuards: true, + dedupeDirectSubmit: true, + clearUserInitiatedCancelOnQueue: true, + swallowErrorAfterUserEventAppend: true, + onQueued: () => setSessChatInput(""), + onBeforeDirectDispatch: () => setSessChatInput(""), + }); } catch (error) { log.error("Error sending message:", error); Message.error(t("errors.failedToSendMessage")); - _sharedSubmitGuard.current = false; - _sharedSubmitPayload.current = null; - failOptimisticTurn(sessionId); - // Close the turn reserved above. If the failure happened inside - // dispatchMessageBySessionType it already marked its own generation - // terminal, in which case this is a no-op generation bump. - forceTurnIdle(sessionId); - if (!userEventAppended) throw error; - // NOT re-thrown after user event append: the message is already visible - // in chat, so restoring the editor would create a duplicate-send risk. + throw error; } finally { setLoading(false); } }, - [ - sessChatInput, - addUserMessage, - enqueueMessage, - sessionMap, - creatorDefaultSelection, - creatorDefaultMode, - dispatchMessageBySessionType, - getSessionId, - setLastUserMessage, - setQueueFlushRequest, - setUserInitiatedCancel, - store, - t, - ] + [getSessionId, sessChatInput, submitUserIntent, t] ); // ============================================ @@ -462,18 +167,12 @@ const useWorkspaceChat = (options: UseWorkspaceChatOptions = {}) => { } setLoading(true); - try { - const submitPayloadKey = buildSubmitPayloadKey(sessionId, content); - await addUserMessage(content); - await dispatchMessageBySessionType( + await submitUserIntent({ sessionId, - content, - undefined, - undefined, - undefined, - `direct:${sessionId}:${stableSubmitHash(submitPayloadKey)}` - ); + displayContent: content, + source: "dispatch", + }); } catch (error) { log.error("Error sending message:", error); Message.error(t("errors.failedToSendMessage")); @@ -481,7 +180,7 @@ const useWorkspaceChat = (options: UseWorkspaceChatOptions = {}) => { setLoading(false); } }, - [addUserMessage, dispatchMessageBySessionType, getSessionId, t] + [getSessionId, submitUserIntent, t] ); // ============================================ diff --git a/src/engines/ChatPanel/index.tsx b/src/engines/ChatPanel/index.tsx index 72bfc401d..ef99a2a46 100644 --- a/src/engines/ChatPanel/index.tsx +++ b/src/engines/ChatPanel/index.tsx @@ -1,5 +1,5 @@ import { useAtom, useAtomValue, useSetAtom } from "jotai"; -import React, { memo, useCallback, useState } from "react"; +import React, { memo, useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; @@ -10,64 +10,45 @@ import { } from "@src/config/mainAppPaths"; import { useRouteViewMode } from "@src/config/routeViewModeConfig"; import { - MAX_WIDTH as CHAT_MAX_WIDTH, - MIN_WIDTH as CHAT_MIN_WIDTH, + CHAT_WIDTH_CSS_VAR, + clampChatWidth, + getChatMaxWidth, } from "@src/engines/ChatPanel/config"; +import SessionCommentsHeaderExtras from "@src/features/Org2Cloud/SessionComments/SessionCommentsHeaderExtras"; import { - clearSessionAtom, - eventCountAtom, - eventsAtom, -} from "@src/engines/SessionCore/core/atoms"; + org2CloudOrgsAtom, + org2CloudOrgsLoadedAtom, +} from "@src/features/Org2Cloud/org2CloudOrgsAtom"; import type { CreatedOrgResult } from "@src/features/TeamCollaboration/components/CreateCollabOrgView"; -import { useDropdownEngine } from "@src/hooks/dropdown"; +import SessionForkHeaderExtras from "@src/features/TeamCollaboration/components/SessionForkHeaderExtras"; import { useShouldOffsetChatPanelHeader } from "@src/hooks/ui/sidebar/useCollapsedSidebarChromeOffset"; import { allAgentDefsAtom } from "@src/modules/MainApp/AgentOrgs/store/builtInAgentsAtom"; -import { useIsCompactLayout } from "@src/modules/shared/layouts/useCompactLayout"; import { getChatPanelBackgroundStyle } from "@src/modules/shared/layouts/viewContainerTokens"; -import { VerticalResizeHandle } from "@src/scaffold/Resize"; -import { GUIDE_TARGETS } from "@src/scaffold/Tutorials"; +import { installAvailableAppUpdate } from "@src/scaffold/AppUpdater"; import { - collabConnectionStatesAtom, - collabMembersAtom, - collabOrgsAtom, - remoteTeammateSessionsAtom, -} from "@src/store/collaboration/collabOrgsAtom"; -import { - COLLAB_CONNECTION_STATUS, - type CollabConnectionStatus, -} from "@src/store/collaboration/types"; + closeCloudOrgManagementChatPanelTabAtom, + openSessionInNewChatTabAtom, + syncActiveChatPanelTabStateAtom, +} from "@src/store/chatPanel/chatPanelTabsAtom"; import { projectListRefreshAtom } from "@src/store/project/projectAtom"; -import { - activeSessionIdAtom, - sessionCreatorStateAtom, - workstationActiveSessionIdAtom, -} from "@src/store/session"; +import { sessionCreatorStateAtom } from "@src/store/session"; +import { tuiModeAtom } from "@src/store/session/tuiModeAtom"; import { resolvedBackgroundConfigAtom } from "@src/store/ui/backgroundConfigAtom"; import { - CHAT_PANEL_SURFACE_KIND, - chatHistoryDisplayModeAtom, chatPanelContentModeAtom, chatPanelCreateProjectContextAtom, chatPanelCreateTargetAtom, - chatPanelExploreAgentSearchEnabledAtom, chatPanelExploreOpenAtom, chatPanelMaximizedAtom, - chatPanelNavigateAtom, - chatPanelSelectedCollabOrgAtom, + chatPanelSelectedCloudOrgAtom, chatPanelSelectedProjectAtom, chatPanelSelectedProjectOrgAtom, chatPanelSelectedWorkItemAtom, chatPanelSelectedWorkspaceAtom, chatPanelStartPageOpenAtom, - chatPanelWorkspaceDashboardOpenAtom, - chatTurnPaginationEnabledAtom, chatWidthAtom, toggleChatPanelMaximizedAtom, } from "@src/store/ui/chatPanelAtom"; -import { - collapseAllCommandAtom, - setAllBlocksCollapsedAtom, -} from "@src/store/ui/collapseStateAtom"; import { sidebarCollapsedAtom } from "@src/store/ui/sidebarAtom"; import type { WorkItemDraft } from "@src/store/workstation/projectManager"; @@ -75,43 +56,26 @@ import { useReloadSession } from "./ChatHistory/hooks/useReloadSession"; import { ChatPanelContent } from "./ChatPanelContent"; import { ChatPanelEmptyContent } from "./ChatPanelEmptyContent"; import { ChatPanelHeader } from "./ChatPanelHeader"; +import { ChatPanelShell } from "./ChatPanelShell"; import { - ChatPanelHeaderBreadcrumb, - ChatPanelSurfaceHeaderPublisher, -} from "./header"; + ChatPanelPlusMenu, + ChatPanelTabBar, + useChatPanelTabShortcuts, +} from "./ChatPanelTabBar"; +import { ChatPanelSurfaceHeaderPublisher } from "./header"; import { useAiWorkItemCreator } from "./hooks/useAiWorkItemCreator"; import { useChatPanelContentState } from "./hooks/useChatPanelContentState"; import { useChatPanelCreateTarget } from "./hooks/useChatPanelCreateTarget"; +import { useChatPanelHeaderActions } from "./hooks/useChatPanelHeaderActions"; +import { useChatPanelNavigationActions } from "./hooks/useChatPanelNavigationActions"; import { useChatPanelResize } from "./hooks/useChatPanelResize"; import { useChatPanelSessionModals } from "./hooks/useChatPanelSessionModals"; +import { useChatPanelTabsController } from "./hooks/useChatPanelTabsController"; import { usePanelTitle } from "./hooks/usePanelTitle"; import { useProjectWorkItemHandlers } from "./hooks/useProjectWorkItemHandlers"; +import { useViewportWidth } from "./hooks/useViewportWidth"; import type { ChatPanelProps, ChatPanelRegionNotice } from "./types"; -const COLLAB_HEADER_STATUS_COLOR: Record = { - [COLLAB_CONNECTION_STATUS.CONNECTED]: "bg-success-6", - [COLLAB_CONNECTION_STATUS.CONNECTING]: "bg-warning-6", - [COLLAB_CONNECTION_STATUS.DISCONNECTED]: "bg-fill-4", - [COLLAB_CONNECTION_STATUS.ERROR]: "bg-danger-6", -}; - -function CollabHeaderStatusPill({ - label, - status, -}: { - label: string; - status: CollabConnectionStatus; -}): React.ReactNode { - return ( - - - {label} - - ); -} - const ChatPanel: React.FC = memo( ({ useExternalWidth = false, @@ -130,19 +94,15 @@ const ChatPanel: React.FC = memo( const isLeftPosition = position === "left"; const shouldOffsetHeaderForCollapsedSidebar = useShouldOffsetChatPanelHeader({ position, useExternalWidth }); - const isCompactLayout = useIsCompactLayout(); const navigate = useNavigate(); const viewMode = useRouteViewMode(); - const { currentSessionId, panelTitle, currentSession } = usePanelTitle(); const activeSession = currentSession ?? undefined; const handleReloadSession = useReloadSession(currentSessionId ?? null); const [contentMode, setContentMode] = useAtom(chatPanelContentModeAtom); const [createTarget, setCreateTarget] = useAtom(chatPanelCreateTargetAtom); - const [startPageOpen, setStartPageOpen] = useAtom( - chatPanelStartPageOpenAtom - ); + const startPageOpen = useAtomValue(chatPanelStartPageOpenAtom); const [workItemCreateDraft, setWorkItemCreateDraft] = useState(null); const [showWorkItemAgentCreator, setShowWorkItemAgentCreator] = useState( @@ -151,17 +111,16 @@ const ChatPanel: React.FC = memo( const [showProjectAgentCreator, setShowProjectAgentCreator] = useState( Boolean(SessionCreatorSlot) ); + const selectedWorkItem = useAtomValue(chatPanelSelectedWorkItemAtom); const selectedProject = useAtomValue(chatPanelSelectedProjectAtom); const selectedProjectOrg = useAtomValue(chatPanelSelectedProjectOrgAtom); const selectedWorkspace = useAtomValue(chatPanelSelectedWorkspaceAtom); - const selectedCollabOrg = useAtomValue(chatPanelSelectedCollabOrgAtom); - const collabOrgs = useAtomValue(collabOrgsAtom); - const collabMembers = useAtomValue(collabMembersAtom); - const collabConnectionStates = useAtomValue(collabConnectionStatesAtom); - const remoteTeammateSessions = useAtomValue(remoteTeammateSessionsAtom); - const workspaceDashboardOpen = useAtomValue( - chatPanelWorkspaceDashboardOpenAtom + const selectedCloudOrg = useAtomValue(chatPanelSelectedCloudOrgAtom); + const cloudOrgs = useAtomValue(org2CloudOrgsAtom); + const cloudOrgsLoaded = useAtomValue(org2CloudOrgsLoadedAtom); + const closeCloudOrgManagementTab = useSetAtom( + closeCloudOrgManagementChatPanelTabAtom ); const exploreOpen = useAtomValue(chatPanelExploreOpenAtom); const createProjectContext = useAtomValue( @@ -169,19 +128,43 @@ const ChatPanel: React.FC = memo( ); const isChatFocus = useAtomValue(chatPanelMaximizedAtom); + const syncActiveTabState = useSetAtom(syncActiveChatPanelTabStateAtom); const toggleChatFocus = useSetAtom(toggleChatPanelMaximizedAtom); const showChatFocusToggle = viewMode === "workStation"; const rawChatWidth = useAtomValue(chatWidthAtom); + const viewportWidth = useViewportWidth(); + const chatMaxWidth = getChatMaxWidth(viewportWidth); const backgroundConfig = useAtomValue(resolvedBackgroundConfigAtom); const chatPanelOpacityStyle = React.useMemo( () => getChatPanelBackgroundStyle(backgroundConfig.pageOpacity), [backgroundConfig.pageOpacity] ); - const chatWidth = - rawChatWidth > 0 ? Math.min(rawChatWidth, CHAT_MAX_WIDTH) : rawChatWidth; + const chatWidth = clampChatWidth(rawChatWidth, viewportWidth); + + // A teammate can lose the selected cloud org while its management panel + // is open (member removal or org deletion). Once the authoritative roster + // has loaded, an absent org is not a recoverable panel state: close the + // stale surface immediately instead of leaving deleted names/actions on + // screen. Keep the selection during the initial unknown-roster phase so + // a cold start does not flicker the panel closed before list_my_orgs lands. + useEffect(() => { + if ( + selectedCloudOrg && + cloudOrgsLoaded && + !cloudOrgs.some((org) => org.orgId === selectedCloudOrg.orgId) + ) { + closeCloudOrgManagementTab(); + } + }, [ + closeCloudOrgManagementTab, + cloudOrgs, + cloudOrgsLoaded, + selectedCloudOrg, + ]); + const chatWidthStyleValue = + chatWidth > 0 ? `var(${CHAT_WIDTH_CSS_VAR})` : chatWidth; const { isDragging, panelRef, handleMouseDown } = useChatPanelResize({ useExternalWidth, - embedded, position, }); @@ -189,20 +172,12 @@ const ChatPanel: React.FC = memo( toggleChatFocus(); }, [toggleChatFocus]); - const openSearchRef = React.useRef<(() => void) | null>(null); - const { - isOpen: isHeaderActionsOpen, - isPositioned: isHeaderActionsPositioned, - toggle: toggleHeaderActionsMenu, - close: closeHeaderActionsMenu, - triggerRef: headerActionsTriggerRef, - panelRef: headerActionsDropdownRef, - panelPosition: headerActionsPosition, - } = useDropdownEngine({ - gap: 4, - align: "right", - placement: "bottom", - }); + const isCliAgentSession = currentSession?.category === "cli_agent"; + const [tuiMode, setTuiMode] = useAtom(tuiModeAtom(currentSessionId ?? "")); + const showTuiModeToggle = Boolean(currentSessionId) && isCliAgentSession; + const handleTuiModeToggle = useCallback(() => { + setTuiMode((prev) => !prev); + }, [setTuiMode]); const [regionNotice, setRegionNotice] = React.useState(null); @@ -213,318 +188,136 @@ const ChatPanel: React.FC = memo( [] ); - const [paginationEnabled, setPaginationEnabled] = useAtom( - chatTurnPaginationEnabledAtom - ); - const [displayMode, setDisplayMode] = useAtom(chatHistoryDisplayModeAtom); - const [exploreAgentSearchEnabled, setExploreAgentSearchEnabled] = useAtom( - chatPanelExploreAgentSearchEnabledAtom - ); - const collapseAllCommand = useAtomValue(collapseAllCommandAtom); - const setAllBlocksCollapsed = useSetAtom(setAllBlocksCollapsedAtom); - const setActiveSessionId = useSetAtom(activeSessionIdAtom); - const navigateChatPanel = useSetAtom(chatPanelNavigateAtom); - const setWorkstationActiveSessionId = useSetAtom( - workstationActiveSessionIdAtom - ); - const setSelectedWorkItem = useSetAtom(chatPanelSelectedWorkItemAtom); - const setSelectedProject = useSetAtom(chatPanelSelectedProjectAtom); - const dispatchClearSession = useSetAtom(clearSessionAtom); + const { + dispatchClearSession, + openWorkItemCreate, + resetActiveSession, + resetToSessionSurface, + setActiveSessionId, + setStartPageOpen, + setWorkstationActiveSessionId, + showSessionSurface, + } = useChatPanelNavigationActions(); + + const { + activeTab, + handleNewSessionTab, + handleNewTerminalTab, + handleOpenCliTerminal, + handleOpenLaunchpadTab, + handleOpenKanbanTab, + isTerminalTabActive, + terminalTabs, + } = useChatPanelTabsController({ + currentSessionId: currentSessionId ?? null, + launchpadTitle: t("navigation:routes.launchpad"), + kanbanTitle: t("sessions:simulator.tabs.kanban"), + showSessionSurface, + }); + const isManagementTabActive = activeTab?.type === "work-management"; + + // Tab shortcuts (⌘W/⌘]/⌘[/⌘N + "create-chat-tab") stay mounted here so + // they keep working while the visual tab strip is hidden off the start page. + useChatPanelTabShortcuts({ + onNewSession: handleNewSessionTab, + onNewTerminal: handleNewTerminalTab, + containerRef: panelRef, + }); + + React.useLayoutEffect(() => { + syncActiveTabState(); + }, [activeTab, syncActiveTabState]); + const creatorState = useAtomValue(sessionCreatorStateAtom); const setCreatorState = useSetAtom(sessionCreatorStateAtom); const bumpProjectListRefresh = useSetAtom(projectListRefreshAtom); const allAgentDefs = useAtomValue(allAgentDefsAtom); const sidebarCollapsed = useAtomValue(sidebarCollapsedAtom); - const allBlocksCollapsed = - collapseAllCommand.epoch > 0 ? collapseAllCommand.collapsed : false; + const { + allBlocksCollapsed, + closeHeaderActionsMenu, + copyEventJsonLabel, + displayMode, + eventCount, + exploreAgentSearchEnabled, + handleCompactDisplayModeToggle, + handleCopyEventJson, + handleExploreAgentSearchToggle, + handleOpenSearch, + handlePaginationToggle, + handleRegisterSearchOpen, + handleReloadFromMenu, + handleStatusBarVisibleToggle, + handleToggleAllBlocksCollapsed, + handleTokenUsageVisibleToggle, + headerActionsDropdownRef, + headerActionsPosition, + headerActionsTriggerRef, + isHeaderActionsOpen, + isHeaderActionsPositioned, + paginationEnabled, + statusBarVisible, + tokenUsageVisible, + toggleHeaderActionsMenu, + } = useChatPanelHeaderActions({ handleReloadSession }); + const collapseToggleLabel = allBlocksCollapsed ? t("common:actions.expandAll") : t("common:actions.collapseAll"); - const handleToggleAllBlocksCollapsed = useCallback(() => { - setAllBlocksCollapsed(!allBlocksCollapsed); - closeHeaderActionsMenu(); - }, [allBlocksCollapsed, closeHeaderActionsMenu, setAllBlocksCollapsed]); - - const handleRegisterSearchOpen = useCallback( - (handler: (() => void) | null) => { - openSearchRef.current = handler; - }, - [] - ); - - const handleOpenSearch = useCallback(() => { - openSearchRef.current?.(); - closeHeaderActionsMenu(); - }, [closeHeaderActionsMenu]); - - const handleReloadFromMenu = useCallback(() => { - handleReloadSession(); - closeHeaderActionsMenu(); - }, [closeHeaderActionsMenu, handleReloadSession]); - - const handlePaginationToggle = useCallback( - (checked: boolean) => { - setPaginationEnabled(checked); + const handleNewSession = resetToSessionSurface; + const handleStartPageNewWorkItem = openWorkItemCreate; + const openLaunchedSessionTab = useSetAtom(openSessionInNewChatTabAtom); + const handleStartPageSessionStart = useCallback( + (info: { sessionId: string }) => { + openLaunchedSessionTab({ sessionId: info.sessionId }); }, - [setPaginationEnabled] + [openLaunchedSessionTab] ); - const handleExploreAgentSearchToggle = useCallback( - (checked: boolean) => { - setExploreAgentSearchEnabled(checked); - }, - [setExploreAgentSearchEnabled] - ); - - const handleCompactDisplayModeToggle = useCallback( - (checked: boolean) => { - setDisplayMode(checked ? "compact" : "full"); - }, - [setDisplayMode] - ); - - const handleNewSession = useCallback(() => { - setStartPageOpen(false); - navigateChatPanel({ kind: CHAT_PANEL_SURFACE_KIND.SESSION }); - dispatchClearSession(); - setWorkstationActiveSessionId(null); - setActiveSessionId(null); - }, [ - dispatchClearSession, - navigateChatPanel, - setActiveSessionId, - setStartPageOpen, - setWorkstationActiveSessionId, - ]); - - const handleOpenStartPage = useCallback(() => { - navigateChatPanel({ kind: CHAT_PANEL_SURFACE_KIND.SESSION }); - setStartPageOpen(true); - dispatchClearSession(); - setWorkstationActiveSessionId(null); - setActiveSessionId(null); - }, [ - dispatchClearSession, - navigateChatPanel, - setActiveSessionId, - setStartPageOpen, - setWorkstationActiveSessionId, - ]); - const handleChatPanelCollabOrgCreated = useCallback( - (result: CreatedOrgResult) => { - if (result.source === "supabase") { - navigateChatPanel({ - kind: CHAT_PANEL_SURFACE_KIND.COLLAB_ORG, - collabOrg: { orgId: result.org.id }, - }); - } else { - bumpProjectListRefresh((previous) => previous + 1); - navigateChatPanel({ kind: CHAT_PANEL_SURFACE_KIND.SESSION }); - } - dispatchClearSession(); - setWorkstationActiveSessionId(null); - setActiveSessionId(null); + (_result: CreatedOrgResult) => { + bumpProjectListRefresh((previous) => previous + 1); + showSessionSurface(); + resetActiveSession(); }, - [ - bumpProjectListRefresh, - dispatchClearSession, - navigateChatPanel, - setActiveSessionId, - setWorkstationActiveSessionId, - ] + [bumpProjectListRefresh, resetActiveSession, showSessionSurface] ); - const eventCount = useAtomValue(eventCountAtom); - const events = useAtomValue(eventsAtom); - const [copyEventJsonLabel, setCopyEventJsonLabel] = React.useState< - "idle" | "copied" | "failed" - >("idle"); - const handleCopyEventJson = useCallback(() => { - const json = JSON.stringify(events, null, 2); - navigator.clipboard - .writeText(json) - .then(() => { - setCopyEventJsonLabel("copied"); - setTimeout(() => setCopyEventJsonLabel("idle"), 2000); - }) - .catch(() => { - setCopyEventJsonLabel("failed"); - setTimeout(() => setCopyEventJsonLabel("idle"), 2000); - }); - closeHeaderActionsMenu(); - }, [closeHeaderActionsMenu, events]); - - const { - handleOpenExportSessionJson, - handleOpenLinkWorkItem, - sessionModals, - } = useChatPanelSessionModals({ - activeSession, - closeHeaderActionsMenu, - currentSessionId: currentSessionId ?? null, - t, - }); - - const { createTargetOptions, handleCreateTargetChange } = - useChatPanelCreateTarget({ - allAgentDefs, - handleNewSession, - sessionCreatorAvailable: Boolean(SessionCreatorSlot), - setCreateTarget, - setCreatorState, - setStartPageOpen, - setShowProjectAgentCreator, - setShowWorkItemAgentCreator, - setWorkItemCreateDraft, - t, - }); - - const handleStartPageNewWorkItem = useCallback(() => { - setStartPageOpen(false); - navigateChatPanel({ kind: CHAT_PANEL_SURFACE_KIND.NEW_WORK_ITEM }); - dispatchClearSession(); - setWorkstationActiveSessionId(null); - setActiveSessionId(null); - }, [ - dispatchClearSession, - navigateChatPanel, - setActiveSessionId, - setStartPageOpen, - setWorkstationActiveSessionId, - ]); - - const handleStartPageSetupRepo = useCallback(() => { - setStartPageOpen(false); - navigateChatPanel({ kind: CHAT_PANEL_SURFACE_KIND.WORKSPACE_DASHBOARD }); - dispatchClearSession(); - setWorkstationActiveSessionId(null); - setActiveSessionId(null); - }, [ - dispatchClearSession, - navigateChatPanel, - setActiveSessionId, - setStartPageOpen, - setWorkstationActiveSessionId, - ]); - - const handleStartPageExploreRepos = useCallback(() => { - setStartPageOpen(false); - navigateChatPanel({ kind: CHAT_PANEL_SURFACE_KIND.WORKSPACE_EXPLORE }); - dispatchClearSession(); - setWorkstationActiveSessionId(null); - setActiveSessionId(null); - }, [ - dispatchClearSession, - navigateChatPanel, - setActiveSessionId, - setStartPageOpen, - setWorkstationActiveSessionId, - ]); - const handleStartPageAddApiKey = useCallback(() => { const accountsPath = `${buildIntegrationsPath({ category: "models" })}?modelsTab=my-accounts`; navigate(buildWizardPath(accountsPath, WIZARD_IDS.KEY_ADD)); }, [navigate]); + const handleStartPageInstallLatestUpdate = useCallback(() => { + void installAvailableAppUpdate(); + }, []); + const sessionSidebarVisible = sessionSidebarWidth > 0; - const collabOrgHeader = React.useMemo(() => { - if (!selectedCollabOrg) return null; - const org = collabOrgs.find( - (candidate) => candidate.id === selectedCollabOrg.orgId - ); - const orgMembers = collabMembers.filter( - (member) => - member.orgId === selectedCollabOrg.orgId && !member.removedAt - ); - const selectedMember = selectedCollabOrg.memberId - ? orgMembers.find((member) => member.id === selectedCollabOrg.memberId) - : null; - const orgSessions = remoteTeammateSessions.filter( - (session) => session.orgId === selectedCollabOrg.orgId - ); - const connectionState = collabConnectionStates.find( - (state) => state.orgId === selectedCollabOrg.orgId - ); - const activeMemberIds = new Set( - orgSessions - .filter((session) => { - if (!session.lastActivityAt) return false; - const date = new Date(session.lastActivityAt); - if (Number.isNaN(date.getTime())) return false; - const now = new Date(); - return ( - date.getFullYear() === now.getFullYear() && - date.getMonth() === now.getMonth() && - date.getDate() === now.getDate() - ); - }) - .map((session) => session.ownerMemberId) - ); - const connected = - connectionState?.status === COLLAB_CONNECTION_STATUS.CONNECTED; - const status: CollabConnectionStatus = selectedMember - ? activeMemberIds.has(selectedMember.id) - ? COLLAB_CONNECTION_STATUS.CONNECTED - : COLLAB_CONNECTION_STATUS.DISCONNECTED - : (connectionState?.status ?? COLLAB_CONNECTION_STATUS.DISCONNECTED); - const statusLabel = selectedMember - ? activeMemberIds.has(selectedMember.id) - ? t("navigation:collaboration.status.activeToday") - : t("navigation:collaboration.status.idle") - : connected - ? t("navigation:collaboration.status.connected") - : t("navigation:collaboration.status.offline"); - const orgTitle = org?.name ?? t("navigation:collaboration.orgDemoTitle"); - const title = selectedMember?.displayName ?? orgTitle; - const breadcrumbItems = selectedMember - ? [ - { key: "org", label: orgTitle }, - { key: "member", label: selectedMember.displayName }, - ] - : [{ key: "org", label: orgTitle }]; - const titleContent = ( - - } - /> - ); - return { title, titleContent }; - }, [ - collabConnectionStates, - collabMembers, - collabOrgs, - remoteTeammateSessions, - selectedCollabOrg, - t, - ]); const contentState = useChatPanelContentState({ active, contentMode, createTarget, currentSessionId: currentSessionId ?? null, exploreOpen, - isChatFocus, panelTitle, - collabOrgHeaderTitle: collabOrgHeader?.title, - collabOrgHeaderTitleContent: collabOrgHeader?.titleContent, - selectedCollabOrg, + cloudOrgHeaderTitle: selectedCloudOrg + ? cloudOrgs.find((org) => org.orgId === selectedCloudOrg.orgId)?.name + : undefined, + selectedCloudOrg, selectedProject, selectedProjectOrg, selectedWorkItem, selectedWorkspace, - workspaceDashboardOpen, - showChatFocusToggle, sidebarCollapsed, sessionCreatorAvailable: Boolean(SessionCreatorSlot), sessionSidebarVisible, viewMode, }); + const setSelectedProject = useSetAtom(chatPanelSelectedProjectAtom); + const setSelectedWorkItem = useSetAtom(chatPanelSelectedWorkItemAtom); const { handleCancelCollabOrgCreate, handleCancelWorkItemCreate, @@ -536,6 +329,7 @@ const ChatPanel: React.FC = memo( handleWorkItemTitleChange, } = useProjectWorkItemHandlers({ bumpProjectListRefresh, + createProjectContext, dispatchClearSession, handleNewSession, selectedProject, @@ -551,13 +345,13 @@ const ChatPanel: React.FC = memo( setWorkItemCreateDraft, setWorkstationActiveSessionId, }); - const { defaultAiWorkItemAssignee, handleAiWorkItemSessionStart, resolveAiWorkItemContext, } = useAiWorkItemCreator({ allAgentDefs, + createProjectContext, creatorState, dispatchClearSession, setActiveSessionId, @@ -572,6 +366,20 @@ const ChatPanel: React.FC = memo( workItemCreateDraft, }); + const { + handleOpenExportSessionJson, + handleOpenLinkWorkItem, + handleOpenCloudShareSettings, + showCloudShareSettings, + sessionModals, + } = useChatPanelSessionModals({ + activeSession, + closeHeaderActionsMenu, + currentSession: currentSession ?? null, + currentSessionId: currentSessionId ?? null, + t, + }); + const showResizeHandle = !useExternalWidth; const borderClasses = embedded && !showResizeHandle @@ -579,20 +387,8 @@ const ChatPanel: React.FC = memo( ? "border-r border-border-1" : "border-l border-border-1" : ""; - const dragHandle = showResizeHandle && ( - - ); - - const chatFocusLabel = isChatFocus - ? t("chat.showWorkstation") - : t("chat.maximizeChatPanel"); const useFullScreenCreator = - isChatFocus || useExternalWidth || chatWidth >= CHAT_MAX_WIDTH; + isChatFocus || useExternalWidth || chatWidth >= chatMaxWidth; const creatorVariant = useFullScreenCreator ? "fullScreen" : "default"; const creatorClassName = "min-h-0 flex-1"; const emptyChatContent = ( @@ -608,12 +404,12 @@ const ChatPanel: React.FC = memo( handleChatPanelProjectCreated={handleChatPanelProjectCreated} handleChatPanelCollabOrgCreated={handleChatPanelCollabOrgCreated} handleChatPanelWorkItemCreated={handleChatPanelWorkItemCreated} + handleOpenCliTerminal={handleOpenCliTerminal} handleRegionNoticeChange={handleRegionNoticeChange} handleStartPageAddApiKey={handleStartPageAddApiKey} - handleStartPageExploreRepos={handleStartPageExploreRepos} - handleStartPageNewSession={handleNewSession} + handleStartPageInstallLatestUpdate={handleStartPageInstallLatestUpdate} + handleStartPageSessionStart={handleStartPageSessionStart} handleStartPageNewWorkItem={handleStartPageNewWorkItem} - handleStartPageSetupRepo={handleStartPageSetupRepo} handleWorkItemAgentCreatorToggle={handleWorkItemAgentCreatorToggle} resolveAiWorkItemContext={resolveAiWorkItemContext} SessionCreatorSlot={SessionCreatorSlot} @@ -626,11 +422,44 @@ const ChatPanel: React.FC = memo( ); const publishSurfaceHeader = - contentState.showBenchmarkSessionGroupContent || - contentState.showExploreContent || - contentState.showWorkspaceDashboardContent || - contentState.showCollabOrgContent || - contentState.showWorkspaceOverviewContent; + !isManagementTabActive && + (startPageOpen || + contentState.showBenchmarkSessionGroupContent || + contentState.showExploreContent || + contentState.showCloudOrgContent || + contentState.showWorkspaceOverviewContent); + + const tabStrip = ; + + const tabStripPlus = ( + + ); + + // Terminal / work-management tabs are not creator surfaces: the create + // target select and presence button would be launcher noise there. Real + // creator surfaces (new work item / project / collab org) keep them. + const showCreatorHeaderControls = + contentState.showNonSessionContent && + !isTerminalTabActive && + !isManagementTabActive; + + const { createTargetOptions, handleCreateTargetChange } = + useChatPanelCreateTarget({ + allAgentDefs, + handleNewSession, + sessionCreatorAvailable: Boolean(SessionCreatorSlot), + setCreateTarget, + setCreatorState, + setStartPageOpen, + setShowProjectAgentCreator, + setShowWorkItemAgentCreator, + setWorkItemCreateDraft, + t, + }); const headerSection = ( <> @@ -663,14 +492,16 @@ const ChatPanel: React.FC = memo( handleExploreAgentSearchToggle={handleExploreAgentSearchToggle} handleOpenExportSessionJson={handleOpenExportSessionJson} handleOpenLinkWorkItem={handleOpenLinkWorkItem} + handleOpenCloudShareSettings={handleOpenCloudShareSettings} handleOpenSearch={handleOpenSearch} handleNewSession={handleNewSession} - handleOpenStartPage={handleOpenStartPage} handlePaginationToggle={handlePaginationToggle} handleProjectAgentCreatorToggle={handleProjectAgentCreatorToggle} handleProjectTitleChange={handleProjectTitleChange} handleReloadFromMenu={handleReloadFromMenu} + handleStatusBarVisibleToggle={handleStatusBarVisibleToggle} handleToggleAllBlocksCollapsed={handleToggleAllBlocksCollapsed} + handleTokenUsageVisibleToggle={handleTokenUsageVisibleToggle} handleWorkItemAgentCreatorToggle={handleWorkItemAgentCreatorToggle} handleWorkItemTitleChange={handleWorkItemTitleChange} headerActionsDropdownRef={headerActionsDropdownRef} @@ -679,11 +510,12 @@ const ChatPanel: React.FC = memo( headerTitle={contentState.headerTitle} headerTitleContent={contentState.headerTitleContent} isChatFocus={isChatFocus} - isCompactLayout={isCompactLayout} isHeaderActionsOpen={isHeaderActionsOpen} isHeaderActionsPositioned={isHeaderActionsPositioned} isProjectTarget={contentState.isProjectTarget} paginationEnabled={paginationEnabled} + statusBarVisible={statusBarVisible} + tokenUsageVisible={tokenUsageVisible} showStartPageBackButton={ !startPageOpen && !contentState.showSessionContent } @@ -697,17 +529,33 @@ const ChatPanel: React.FC = memo( } showChatFocusToggle={showChatFocusToggle} showCreatorPresenceInHeader={contentState.showCreatorPresenceInHeader} - showHeader={contentState.showHeader} + showHeader={contentState.showHeader || isManagementTabActive} showExploreAgentSwitchInHeader={contentState.showExploreContent} showNewSessionButton={contentState.showNewSessionButton} - showNonSessionContent={contentState.showNonSessionContent} + showNonSessionContent={showCreatorHeaderControls} showProjectAgentCreator={showProjectAgentCreator} showProjectAgentSwitchInHeader={ contentState.showProjectAgentSwitchInHeader } - showSessionContent={contentState.showSessionContent} + showSessionContent={ + contentState.showSessionContent && !isManagementTabActive + } + showCloudShareSettings={showCloudShareSettings} showStartPage={startPageOpen} showWorkItemAgentCreator={showWorkItemAgentCreator} + showTuiModeToggle={showTuiModeToggle} + tuiMode={tuiMode} + handleTuiModeToggle={handleTuiModeToggle} + tabStrip={tabStrip} + tabStripPlus={tabStripPlus} + sessionHeaderExtras={ + <> + {/* Session-level cloud notes (Phase F) — renders null for + non-cloud sessions, exactly like the fork extras. */} + + + + } showWorkItemAgentSwitchInHeader={ contentState.showWorkItemAgentSwitchInHeader } @@ -720,15 +568,13 @@ const ChatPanel: React.FC = memo( const chatColumn = ( = memo( showBenchmarkSessionGroupContent={ contentState.showBenchmarkSessionGroupContent } - showCollabOrgContent={contentState.showCollabOrgContent} - showEmptyChatFocusRestoreButton={ - contentState.showEmptyChatFocusRestoreButton - } + showCloudOrgContent={contentState.showCloudOrgContent} showExploreContent={contentState.showExploreContent} showPanelContent={contentState.showPanelContent} showProjectContent={contentState.showProjectContent} showProjectOrgContent={contentState.showProjectOrgContent} showSessionContent={contentState.showSessionContent} showWorkItemContent={contentState.showWorkItemContent} - showWorkspaceDashboardContent={ - contentState.showWorkspaceDashboardContent - } showWorkspaceOverviewContent={contentState.showWorkspaceOverviewContent} /> ); - const mainPanel = ( -
0 ? CHAT_MIN_WIDTH : undefined, - borderRadius: embedded ? 0 : "var(--radius-page)", - contain: isDragging ? "strict" : undefined, - willChange: isDragging ? "width" : undefined, - ...chatPanelOpacityStyle, - }} - > - {headerSection} - {chatColumn} -
- ); - - const panelChildren = isLeftPosition - ? [mainPanel, dragHandle] - : [dragHandle, mainPanel]; - return ( - <> -
- {panelChildren} -
- {sessionModals} - + ); } ); diff --git a/src/engines/ChatPanel/navigation/chatPanelSurfaceReducer.test.ts b/src/engines/ChatPanel/navigation/chatPanelSurfaceReducer.test.ts index 439ab5b91..473bd951a 100644 --- a/src/engines/ChatPanel/navigation/chatPanelSurfaceReducer.test.ts +++ b/src/engines/ChatPanel/navigation/chatPanelSurfaceReducer.test.ts @@ -27,7 +27,7 @@ const sampleWorkspace = { } satisfies ChatPanelSelectedWorkspace; describe("reduceChatPanelSurfaceCommand", () => { - it("clears Explore and dashboard state when navigating to New Work Item", () => { + it("clears Explore state when navigating to New Work Item", () => { const snapshot = reduceChatPanelSurfaceCommand({ kind: CHAT_PANEL_SURFACE_KIND.NEW_WORK_ITEM, }); @@ -35,13 +35,12 @@ describe("reduceChatPanelSurfaceCommand", () => { expect(snapshot.contentMode).toBe(CHAT_PANEL_CONTENT_MODE.NON_SESSION); expect(snapshot.createTarget).toBe(CHAT_PANEL_CREATE_TARGET.WORK_ITEM); expect(snapshot.exploreOpen).toBe(false); - expect(snapshot.workspaceDashboardOpen).toBe(false); expect(snapshot.selectedWorkspace).toBeNull(); expect(snapshot.selectedProject).toBeNull(); expect(snapshot.selectedWorkItem).toBeNull(); }); - it("clears Explore and dashboard state when navigating to New Project", () => { + it("clears Explore state when navigating to New Project", () => { const snapshot = reduceChatPanelSurfaceCommand({ kind: CHAT_PANEL_SURFACE_KIND.NEW_PROJECT, }); @@ -49,7 +48,6 @@ describe("reduceChatPanelSurfaceCommand", () => { expect(snapshot.contentMode).toBe(CHAT_PANEL_CONTENT_MODE.NON_SESSION); expect(snapshot.createTarget).toBe(CHAT_PANEL_CREATE_TARGET.PROJECT); expect(snapshot.exploreOpen).toBe(false); - expect(snapshot.workspaceDashboardOpen).toBe(false); }); it("opening a session clears non-session surfaces", () => { @@ -60,7 +58,6 @@ describe("reduceChatPanelSurfaceCommand", () => { expect(snapshot.contentMode).toBe(CHAT_PANEL_CONTENT_MODE.SESSION); expect(snapshot.createTarget).toBe(CHAT_PANEL_CREATE_TARGET.AGENT_SESSION); expect(snapshot.exploreOpen).toBe(false); - expect(snapshot.workspaceDashboardOpen).toBe(false); expect(snapshot.selectedWorkItem).toBeNull(); }); @@ -76,7 +73,7 @@ describe("reduceChatPanelSurfaceCommand", () => { expect(snapshot.exploreOpen).toBe(false); }); - it("opens workspace overview details without dashboard or Explore", () => { + it("opens workspace overview details without Explore", () => { const snapshot = reduceChatPanelSurfaceCommand({ kind: CHAT_PANEL_SURFACE_KIND.WORKSPACE_OVERVIEW, workspace: sampleWorkspace, @@ -85,32 +82,46 @@ describe("reduceChatPanelSurfaceCommand", () => { expect(snapshot.selectedWorkspace).toBe(sampleWorkspace); expect(snapshot.workspaceOverviewTab).toBe(WORKSPACE_OVERVIEW_TAB.DETAILS); - expect(snapshot.workspaceDashboardOpen).toBe(false); expect(snapshot.exploreOpen).toBe(false); }); - it("preserves workspace overview tab when command omits tab", () => { - const currentSnapshot = reduceChatPanelSurfaceCommand({ - kind: CHAT_PANEL_SURFACE_KIND.WORKSPACE_OVERVIEW, - workspace: sampleWorkspace, - tab: WORKSPACE_OVERVIEW_TAB.RECENT_SESSION, + it("keeps the org create context when an org panel opens New Work Item", () => { + // Org-panel flow: an org surface navigates to NEW_WORK_ITEM carrying the + // aliased project org. Tearing down the org surface must not drop the + // context — it is what scopes the standalone write to the org. + const cloudOrgSnapshot = reduceChatPanelSurfaceCommand({ + kind: CHAT_PANEL_SURFACE_KIND.CLOUD_ORG, + cloudOrg: { orgId: "cloud-org-1" }, }); - const nextSnapshot = reduceChatPanelSurfaceCommand( + const createContext = { + orgId: "project-org-1", + scopeBreadcrumbLabel: "Acme Team", + }; + const snapshot = reduceChatPanelSurfaceCommand( { - kind: CHAT_PANEL_SURFACE_KIND.WORKSPACE_OVERVIEW, - workspace: { - ...sampleWorkspace, - id: "repo-2", - name: "Repo 2", - path: "/tmp/repo-2", - }, + kind: CHAT_PANEL_SURFACE_KIND.NEW_WORK_ITEM, + createProjectContext: createContext, }, - currentSnapshot + cloudOrgSnapshot ); - expect(nextSnapshot.workspaceOverviewTab).toBe( - WORKSPACE_OVERVIEW_TAB.RECENT_SESSION + expect(snapshot.createTarget).toBe(CHAT_PANEL_CREATE_TARGET.WORK_ITEM); + expect(snapshot.createProjectContext).toEqual(createContext); + expect(snapshot.selectedCloudOrg).toBeNull(); + }); + + it("drops a stale create context when New Work Item navigates without one", () => { + const scopedSnapshot = reduceChatPanelSurfaceCommand({ + kind: CHAT_PANEL_SURFACE_KIND.NEW_WORK_ITEM, + createProjectContext: { orgId: "project-org-1" }, + }); + + const snapshot = reduceChatPanelSurfaceCommand( + { kind: CHAT_PANEL_SURFACE_KIND.NEW_WORK_ITEM }, + scopedSnapshot ); + + expect(snapshot.createProjectContext).toBeNull(); }); }); diff --git a/src/engines/ChatPanel/navigation/chatPanelSurfaceReducer.ts b/src/engines/ChatPanel/navigation/chatPanelSurfaceReducer.ts index 50d69c62a..193a45087 100644 --- a/src/engines/ChatPanel/navigation/chatPanelSurfaceReducer.ts +++ b/src/engines/ChatPanel/navigation/chatPanelSurfaceReducer.ts @@ -6,7 +6,7 @@ import { type ChatPanelCreateProjectContext, type ChatPanelCreateTarget, type ChatPanelNavigateCommand, - type ChatPanelSelectedCollabOrg, + type ChatPanelSelectedCloudOrg, type ChatPanelSelectedProject, type ChatPanelSelectedProjectOrg, type ChatPanelSelectedWorkItem, @@ -24,8 +24,7 @@ export interface ChatPanelSurfaceSnapshot { selectedProject: ChatPanelSelectedProject | null; selectedProjectOrg: ChatPanelSelectedProjectOrg | null; selectedWorkspace: ChatPanelSelectedWorkspace | null; - selectedCollabOrg: ChatPanelSelectedCollabOrg | null; - workspaceDashboardOpen: boolean; + selectedCloudOrg: ChatPanelSelectedCloudOrg | null; exploreOpen: boolean; workspaceOverviewTab: WorkspaceOverviewTab; } @@ -38,8 +37,7 @@ export const EMPTY_CHAT_PANEL_SURFACE_SNAPSHOT: ChatPanelSurfaceSnapshot = { selectedProject: null, selectedProjectOrg: null, selectedWorkspace: null, - selectedCollabOrg: null, - workspaceDashboardOpen: false, + selectedCloudOrg: null, exploreOpen: false, workspaceOverviewTab: WORKSPACE_OVERVIEW_TAB.OVERVIEW, }; @@ -70,6 +68,12 @@ export function reduceChatPanelSurfaceCommand( createTarget: CHAT_PANEL_CREATE_TARGET.PROJECT, createProjectContext: command.createProjectContext ?? null, }; + case CHAT_PANEL_SURFACE_KIND.NEW_GITHUB_ISSUES_PROJECT: + return { + ...next, + createTarget: CHAT_PANEL_CREATE_TARGET.GITHUB_ISSUES_PROJECT, + createProjectContext: command.createProjectContext ?? null, + }; case CHAT_PANEL_SURFACE_KIND.NEW_WORK_ITEM: return { ...next, @@ -99,7 +103,7 @@ export function reduceChatPanelSurfaceCommand( case CHAT_PANEL_SURFACE_KIND.WORKSPACE_DASHBOARD: return { ...next, - workspaceDashboardOpen: true, + contentMode: CHAT_PANEL_CONTENT_MODE.SESSION, }; case CHAT_PANEL_SURFACE_KIND.WORKSPACE_EXPLORE: return { @@ -113,10 +117,10 @@ export function reduceChatPanelSurfaceCommand( workspaceOverviewTab: command.tab ?? currentSnapshot.workspaceOverviewTab, }; - case CHAT_PANEL_SURFACE_KIND.COLLAB_ORG: + case CHAT_PANEL_SURFACE_KIND.CLOUD_ORG: return { ...next, - selectedCollabOrg: command.collabOrg, + selectedCloudOrg: command.cloudOrg, }; } } diff --git a/src/engines/ChatPanel/panels/AgentBlamePanelView.tsx b/src/engines/ChatPanel/panels/AgentBlamePanelView.tsx deleted file mode 100644 index 8e775696a..000000000 --- a/src/engines/ChatPanel/panels/AgentBlamePanelView.tsx +++ /dev/null @@ -1,618 +0,0 @@ -import { - Bot, - FileText, - GitCommit, - Loader2, - RefreshCw, - Square, - Users, -} from "lucide-react"; -import React, { memo, useCallback, useEffect, useMemo, useState } from "react"; -import { useTranslation } from "react-i18next"; - -import { - type OrgtrackFileSessionLookup, - type OrgtrackIndex, - type OrgtrackScanProgress, - type OrgtrackTier, - cancelOrgtrackScan, - getOrgtrackIndex, - getOrgtrackScanStatus, - lookupOrgtrackFileSessions, - startOrgtrackScan, -} from "@src/api/tauri/lineage"; -import Button from "@src/components/Button"; -import InlineAlert from "@src/components/InlineAlert"; -import { - CollapsibleSection, - DETAIL_PANEL_TOKENS, -} from "@src/modules/shared/layouts/blocks"; -import { formatRelativeTime } from "@src/util/time/formatRelativeTime"; - -interface AgentBlamePanelViewProps { - repoPath: string; -} - -interface StatCardProps { - icon: React.ReactNode; - label: string; - value: string | number; -} - -const SCAN_POLL_INTERVAL_MS = 750; -const INDEX_REFRESH_INTERVAL_MS = 3_000; - -const StatCard: React.FC = ({ icon, label, value }) => ( -
-
- {icon} - {label} -
-
- {typeof value === "number" ? value.toLocaleString() : value} -
-
-); - -function formatGeneratedAt(value: string): string { - const timestamp = Date.parse(value); - if (Number.isNaN(timestamp)) return value; - return formatRelativeTime(timestamp, "short"); -} - -function scanPercent(scanProgress: OrgtrackScanProgress | null): number { - if (!scanProgress || scanProgress.total <= 0) return 0; - return Math.min( - 100, - Math.round((scanProgress.processed / scanProgress.total) * 100) - ); -} - -const AgentBlamePanelView: React.FC = memo( - ({ repoPath }) => { - const { t } = useTranslation("common"); - const [index, setIndex] = useState(null); - const [scanProgress, setScanProgress] = - useState(null); - const [selectedTier, setSelectedTier] = useState("meta"); - const [fileQuery, setFileQuery] = useState(""); - const [fileLookup, setFileLookup] = - useState(null); - const [fileLookupLoading, setFileLookupLoading] = useState(false); - const [loading, setLoading] = useState(false); - const [scanActionPending, setScanActionPending] = useState(false); - const [error, setError] = useState(null); - - const loadIndex = useCallback( - async (cancelledRef?: { current: boolean }) => { - if (!repoPath) { - setIndex(null); - return; - } - - setLoading(true); - setError(null); - try { - const nextIndex = await getOrgtrackIndex(repoPath); - if (!cancelledRef?.current) { - setIndex(nextIndex); - } - } catch (err) { - if (!cancelledRef?.current) { - setError(err instanceof Error ? err.message : String(err)); - setIndex(null); - } - } finally { - if (!cancelledRef?.current) { - setLoading(false); - } - } - }, - [repoPath] - ); - - const loadScanStatus = useCallback( - async (cancelledRef?: { current: boolean }) => { - if (!repoPath) { - setScanProgress(null); - return; - } - const nextStatus = await getOrgtrackScanStatus(repoPath); - if (!cancelledRef?.current) { - setScanProgress(nextStatus); - if (nextStatus?.tier) { - setSelectedTier(nextStatus.tier); - } - } - }, - [repoPath] - ); - - useEffect(() => { - const cancelledRef = { current: false }; - void loadIndex(cancelledRef); - void loadScanStatus(cancelledRef); - return () => { - cancelledRef.current = true; - }; - }, [loadIndex, loadScanStatus]); - - useEffect(() => { - if (scanProgress?.status !== "running") return; - - let cancelled = false; - let lastIndexRefresh = 0; - const interval = window.setInterval(() => { - const cancelledRef = { current: cancelled }; - void loadScanStatus(cancelledRef).then(() => { - const now = Date.now(); - if (now - lastIndexRefresh >= INDEX_REFRESH_INTERVAL_MS) { - lastIndexRefresh = now; - void loadIndex(cancelledRef); - } - }); - }, SCAN_POLL_INTERVAL_MS); - - return () => { - cancelled = true; - window.clearInterval(interval); - }; - }, [loadIndex, loadScanStatus, scanProgress?.status]); - - useEffect(() => { - if (scanProgress?.status === "completed") { - void loadIndex(); - } - }, [loadIndex, scanProgress?.status]); - - const handleStartScan = useCallback( - async (options?: { resume?: boolean; rebuild?: boolean }) => { - if (!repoPath || scanActionPending) return; - const allowRawTrajectory = selectedTier === "trajectory"; - if ( - allowRawTrajectory && - !window.confirm(t("labels.orgtrackTrajectoryConfirm")) - ) { - return; - } - - setScanActionPending(true); - setError(null); - try { - const nextProgress = await startOrgtrackScan({ - repoPath, - tier: selectedTier, - allowRawTrajectory, - resume: options?.resume ?? true, - rebuild: options?.rebuild ?? false, - }); - setScanProgress(nextProgress); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - setScanActionPending(false); - } - }, - [repoPath, scanActionPending, selectedTier, t] - ); - - const handleCancelScan = useCallback(async () => { - if (!repoPath || scanActionPending) return; - setScanActionPending(true); - setError(null); - try { - const nextProgress = await cancelOrgtrackScan(repoPath); - setScanProgress(nextProgress); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - setScanActionPending(false); - } - }, [repoPath, scanActionPending]); - - const handleFileLookup = useCallback(async () => { - const trimmedQuery = fileQuery.trim(); - if (!repoPath || !trimmedQuery) { - setFileLookup(null); - return; - } - - setFileLookupLoading(true); - setError(null); - try { - setFileLookup( - await lookupOrgtrackFileSessions({ - repoPath, - filePath: trimmedQuery, - }) - ); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - setFileLookup(null); - } finally { - setFileLookupLoading(false); - } - }, [fileQuery, repoPath]); - - const stats = useMemo(() => { - const sessions = - scanProgress?.counts.sessions ?? index?.sessions.length ?? 0; - const files = scanProgress?.counts.files ?? index?.files.length ?? 0; - const commits = - scanProgress?.counts.commits ?? index?.commits.length ?? 0; - const entries = - scanProgress?.counts.entries ?? - index?.files.reduce((sum, file) => sum + file.entriesCount, 0) ?? - 0; - const records = scanProgress?.counts.records ?? 0; - - return { sessions, files, commits, entries, records }; - }, [index, scanProgress]); - - const topSessions = useMemo( - () => - [...(index?.sessions ?? [])] - .sort((left, right) => right.filesCount - left.filesCount) - .slice(0, 6), - [index?.sessions] - ); - - const topFiles = useMemo( - () => - [...(index?.files ?? [])] - .sort((left, right) => right.entriesCount - left.entriesCount) - .slice(0, 8), - [index?.files] - ); - - const percent = scanPercent(scanProgress); - const isRunning = scanProgress?.status === "running"; - const canResume = - scanProgress?.resumable && - (scanProgress.status === "failed" || scanProgress.status === "cancelled"); - - return ( -
- -
-
- {error ? ( - - {error} - - ) : null} - -
-
-
- - {t("labels.agentBlame")} - - - {scanProgress?.status ?? t("labels.orgtrackScanIdle")} - - {index ? ( - - {index.exportedTier} - - ) : null} -
-

- {index - ? `${t("labels.generated")} ${formatGeneratedAt( - index.generatedAt - )}` - : t("labels.initializeOrgtrackMetadataHint")} -

-
- -
- - {isRunning ? ( - - ) : ( - - )} - {canResume ? ( - - ) : null} - {!isRunning && index ? ( - - ) : null} -
-
- - {scanProgress ? ( -
-
- - {t("labels.orgtrackScanPhase")}: {scanProgress.phase} - - {percent}% -
-
-
-
- {scanProgress.lastError ? ( -
- {scanProgress.lastError} -
- ) : null} -
- ) : loading ? ( -
- - {t("labels.loadingAgentBlame")} -
- ) : null} - -
- } - label={t("labels.sessions")} - value={stats.sessions} - /> - } - label={t("labels.files")} - value={stats.files} - /> - } - label={t("labels.commits")} - value={stats.commits} - /> - } - label={t("labels.entries")} - value={stats.entries} - /> - } - label={t("labels.records")} - value={stats.records} - /> -
- - {index?.summary ? ( -
-
-
- {t("labels.sessionsByAppType")} -
-
- {index.summary.sessionsByAppType.map((bucket) => ( -
- - {bucket.label} - - - {bucket.count.toLocaleString()} - -
- ))} -
-
-
-
- {t("labels.modelsUsed")} -
-
- {index.summary.modelsUsed.map((bucket) => ( -
- - {bucket.label} - - - {bucket.count.toLocaleString()} - -
- ))} -
-
-
- ) : null} - -
-
- {t("labels.lookupFileSessions")} -
-
- setFileQuery(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter") { - void handleFileLookup(); - } - }} - /> - -
- {fileLookup ? ( -
- {fileLookup.sessions.length > 0 ? ( - fileLookup.sessions.map((session) => ( -
-
- {session.sessionLabel ?? session.sessionId} -
-
- {formatRelativeTime( - session.lastEditAt * 1000, - "compact" - )}{" "} - · {session.editCount.toLocaleString()}{" "} - {t("labels.entries")} ·{" "} - {session.commitShas.length.toLocaleString()}{" "} - {t("labels.commits")} -
- {session.commitShas.length > 0 ? ( -
- {t("labels.appliedCommits")}:{" "} - {session.commitShas.join(", ")} -
- ) : ( -
- {t("labels.noAppliedCommit")} -
- )} -
- )) - ) : ( -
- {t("labels.noSessionsForFile")} -
- )} -
- ) : null} -
-
-
- - - -
- {topSessions.length > 0 ? ( -
- {topSessions.map((session) => ( -
-
- {session.label || session.sessionId} -
-
- {session.filesCount.toLocaleString()} {t("labels.files")}{" "} - · {session.commitsCount.toLocaleString()}{" "} - {t("labels.commits")} -
-
- ))} -
- ) : ( -
- {t("labels.noSessions")} -
- )} -
-
- - -
- {topFiles.length > 0 ? ( -
- {topFiles.map((file) => ( -
-
- {file.path} -
-
- {file.sessionsCount.toLocaleString()}{" "} - {t("labels.sessions")} ·{" "} - {file.commitsCount.toLocaleString()} {t("labels.commits")}{" "} - · {file.entriesCount.toLocaleString()}{" "} - {t("labels.entries")} -
-
- ))} -
- ) : ( -
- {t("labels.noFiles")} -
- )} -
-
-
- ); - } -); - -AgentBlamePanelView.displayName = "AgentBlamePanelView"; - -export default AgentBlamePanelView; diff --git a/src/engines/ChatPanel/panels/BenchmarkRunBuilder.tsx b/src/engines/ChatPanel/panels/BenchmarkRunBuilder.tsx index 1c161dc23..3d00157d2 100644 --- a/src/engines/ChatPanel/panels/BenchmarkRunBuilder.tsx +++ b/src/engines/ChatPanel/panels/BenchmarkRunBuilder.tsx @@ -35,9 +35,9 @@ import { modelPickerStyleAtom } from "@src/store/ui/chatPanelAtom"; import { getRustAgentType } from "@src/util/session/sessionDispatch"; interface BenchmarkRunBuilderProps { - bodySlot: React.ReactNode; + bodySlot?: React.ReactNode; className: string; - footerSlot: React.ReactNode; + footerSlot?: React.ReactNode; } export function BenchmarkRunBuilder({ diff --git a/src/engines/ChatPanel/panels/CloudOrgPanelView/CloudSessionsSection.tsx b/src/engines/ChatPanel/panels/CloudOrgPanelView/CloudSessionsSection.tsx new file mode 100644 index 000000000..3a3c01241 --- /dev/null +++ b/src/engines/ChatPanel/panels/CloudOrgPanelView/CloudSessionsSection.tsx @@ -0,0 +1,183 @@ +import React, { useCallback, useMemo } from "react"; +import { useTranslation } from "react-i18next"; + +import Button from "@src/components/Button"; +import ModelIcon from "@src/components/ModelIcon"; +import { useCloudOrgRemoteSessions } from "@src/features/Org2Cloud/org2CloudRemoteSessionsAtom"; +import { useCloudSessionActions } from "@src/features/Org2Cloud/useCloudSessionActions"; +import { useOpenCloudBilling } from "@src/features/Org2Cloud/useOpenCloudBilling"; +import { + Placeholder, + SessionTable, + type SessionTableColumnKey, + type SessionTableItem, +} from "@src/modules/shared/layouts/blocks"; +import { toIntlLocaleTag } from "@src/util/data/formatters/date"; + +import { toCloudSessionTableItem } from "./cloudSessionTableItem"; + +const CLOUD_SESSION_COLUMN_VISIBILITY: Partial< + Record +> = { + owner: true, + impact: false, + filesChanged: false, + relatedCommits: false, + committedRate: false, + tokens: false, + started: false, +}; + +interface CloudSessionsSectionProps { + orgId: string; +} + +export function CloudSessionsSection({ orgId }: CloudSessionsSectionProps) { + const { t, i18n } = useTranslation(["navigation", "common"]); + const { rows, state, refresh } = useCloudOrgRemoteSessions(orgId); + const { + replaySession, + forkSession, + busySessionRowId, + retentionExpiredRowId, + } = useCloudSessionActions(orgId); + const openCloudBillingPage = useOpenCloudBilling(); + + const visibleSessions = useMemo( + () => rows.filter((session) => !session.deletedAt), + [rows] + ); + const dateTimeOptions = useMemo( + () => ({ + yesterdayLabel: t("common:relativeDate.yesterday"), + locale: toIntlLocaleTag(i18n.resolvedLanguage), + }), + [i18n.resolvedLanguage, t] + ); + const tableItems = useMemo( + () => + visibleSessions.map((session) => { + const item = toCloudSessionTableItem( + session, + { + fullReplay: t("navigation:cloud.syncLevel.modeFullReplay"), + metadataOnly: t("navigation:cloud.syncLevel.modeMetadata"), + notPublished: t("navigation:cloud.sidebar.notPublished"), + }, + dateTimeOptions + ); + const replayable = session.eventsEpoch !== undefined; + return { + ...item, + agentIcon: session.cliAgentType ? ( + + ) : undefined, + modelIcon: session.model ? ( + + ) : undefined, + rowAction: replayable ? ( + + ) : undefined, + }; + }), + [busySessionRowId, dateTimeOptions, forkSession, t, visibleSessions] + ); + + const handleSelectSession = useCallback( + (item: SessionTableItem) => { + const session = visibleSessions.find( + (candidate) => candidate.id === item.id + ); + if (!session || busySessionRowId) return; + void replaySession(session); + }, + [busySessionRowId, replaySession, visibleSessions] + ); + + if (state === "idle" || state === "loading") { + return ( + + ); + } + + if (state === "error") { + return ( + + ); + } + + if (visibleSessions.length === 0) { + return ( + + ); + } + + return ( +
+ + {retentionExpiredRowId ? ( +
+ {t("navigation:cloud.orgPanel.retentionUpgrade")} + +
+ ) : null} +
+ ); +} + +export default CloudSessionsSection; diff --git a/src/engines/ChatPanel/panels/CloudOrgPanelView/ManagementSections.tsx b/src/engines/ChatPanel/panels/CloudOrgPanelView/ManagementSections.tsx new file mode 100644 index 000000000..2f9e63db1 --- /dev/null +++ b/src/engines/ChatPanel/panels/CloudOrgPanelView/ManagementSections.tsx @@ -0,0 +1,776 @@ +/** + * Management sections of `CloudOrgPanelView` (managed-cloud mirror of the + * self-hosted `CollabOrgPanelView/MembersSection`): + * + * - `CloudInvitesCard` (admin) — create invite (role + max uses + optional + * expiry), one-time copyable `orgii://cloud/join` link, inventory with + * usage/state, revoke. + * - `CloudMembersSection` — member rows; admins get a role dropdown + * (admin/member) and Remove; everyone but the owner gets Leave + * with an inline confirm (the owner must transfer or delete instead). + * - `CloudOrgSettingsSection` (admin/owner) — rename; owner-only transfer + * picker and delete with typed name confirmation. + * + * All handlers/state come from `useCloudOrgManagement`; these components + * are render-only. + */ +import type { TFunction } from "i18next"; +import React, { useMemo, useState } from "react"; + +import Button from "@src/components/Button"; +import Input from "@src/components/Input"; +import Select from "@src/components/Select"; +import type { CloudOrgMember } from "@src/features/Org2Cloud/org2CloudClient"; +import { + CLOUD_ASSIGNABLE_ROLES, + CLOUD_INVITE_STATE, + type CloudAssignableRole, + type CloudInviteRecord, + deriveCloudInviteState, + getCloudInviteRemainingUses, + isCloudAssignableRole, +} from "@src/features/Org2Cloud/org2CloudOrgManagement"; +import { + SECTION_ACTION_GAP_CLASSES, + SECTION_CONTROL_STYLE, + SECTION_VALUE_SMALL_MUTED_CLASSES, + SectionContainer, + SectionRow, +} from "@src/modules/shared/layouts/SectionLayout"; +import { + DEFAULT_INVITE_EXPIRY_DAYS, + PANEL_INVITE_USAGE_LIMIT, +} from "@src/store/collaboration/inviteDefaults"; +import { + COLLAB_SESSION_ACCESS_MODE, + type CollabSessionAccessMode, +} from "@src/store/collaboration/types"; +import { formatSmartDateTime } from "@src/util/data/formatters/date"; +import { confirmDestructiveAction } from "@src/util/dialogs/confirmDestructiveAction"; + +import type { + CloudOrgManagement, + CreateCloudInviteOptions, +} from "./useCloudOrgManagement"; + +const INVITE_USAGE_LIMIT_OPTIONS = [1, 5, 10, 25] as const; +/** 0 is the "never expires" sentinel (maps to a null expiry). */ +const INVITE_EXPIRY_DAY_OPTIONS = [1, 7, 30, 0] as const; +const MEMBER_ROLE_CONTROL_STYLE = { + ...SECTION_CONTROL_STYLE, + width: 132, +} as const; +function roleLabel( + t: TFunction<"navigation">, + role: CloudAssignableRole +): string { + return role === "admin" + ? t("cloud.orgManagement.invites.roleAdmin") + : t("cloud.orgManagement.invites.roleMember"); +} + +function CloudBadge({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +// --------------------------------------------------------------------------- +// Invites +// --------------------------------------------------------------------------- + +interface CloudInvitesCardProps { + t: TFunction<"navigation">; + management: CloudOrgManagement; +} + +export function CloudInvitesCard({ t, management }: CloudInvitesCardProps) { + const { + invites, + inviteListError, + creatingInvite, + copyingInvite, + inviteError, + revokingInviteId, + latestCreatedInvite, + handleCreateInvite, + handleCopyInvite, + handleRevokeInvite, + } = management; + + const [usageLimit, setUsageLimit] = useState( + PANEL_INVITE_USAGE_LIMIT + ); + const [expiresInDays, setExpiresInDays] = useState( + DEFAULT_INVITE_EXPIRY_DAYS + ); + const [role, setRole] = useState("member"); + + const usageOptions = useMemo( + () => + INVITE_USAGE_LIMIT_OPTIONS.map((limit) => ({ + value: limit, + label: String(limit), + dataTestId: `cloud-org-invite-usage-${limit}`, + })), + [] + ); + const expiryOptions = useMemo( + () => + INVITE_EXPIRY_DAY_OPTIONS.map((days) => ({ + value: days, + dataTestId: `cloud-org-invite-expiry-${days}`, + label: + days === 0 + ? t("cloud.orgManagement.invites.expiryOptionNever") + : days === 1 + ? t("cloud.orgManagement.invites.expiryOption1d") + : days === 7 + ? t("cloud.orgManagement.invites.expiryOption7d") + : t("cloud.orgManagement.invites.expiryOption30d"), + })), + [t] + ); + const roleOptions = useMemo( + () => + CLOUD_ASSIGNABLE_ROLES.map((value) => ({ + value, + label: roleLabel(t, value), + dataTestId: `cloud-org-invite-role-${value}`, + })), + [t] + ); + + // No window.confirm here: the blocking native dialog leaves a stale-paint + // ghost of the row in WebKit after dismissal (repaints only on the next + // interaction). Revoking an invite is low-stakes — a new one is one click + // away — so the loading state on the button is confirmation enough. + const handleRevoke = (invite: CloudInviteRecord) => { + void handleRevokeInvite(invite); + }; + + const handleCreate = () => { + const options: CreateCloudInviteOptions = { + usageLimit, + expiresInDays: expiresInDays === 0 ? null : expiresInDays, + role, + }; + void handleCreateInvite(options); + }; + + return ( + +
+ {invites.length === 0 ? ( + + ) : ( + invites.map((invite) => { + const state = deriveCloudInviteState(invite); + const active = state === CLOUD_INVITE_STATE.ACTIVE; + const inviteStatus = active + ? `${t("cloud.orgManagement.invites.remainingUses", { + uses: getCloudInviteRemainingUses(invite), + })} · ${ + invite.expiresAt + ? t("cloud.orgManagement.invites.expires", { + date: formatSmartDateTime(invite.expiresAt), + }) + : t("cloud.orgManagement.invites.neverExpires") + }` + : t( + state === CLOUD_INVITE_STATE.REVOKED + ? "cloud.orgManagement.invites.stateRevoked" + : state === CLOUD_INVITE_STATE.EXPIRED + ? "cloud.orgManagement.invites.stateExpired" + : "cloud.orgManagement.invites.stateExhausted" + ); + return ( +
+ + {roleLabel(t, invite.role)} + + {t("cloud.orgManagement.invites.createdAt", { + date: formatSmartDateTime(invite.createdAt), + })} + + + } + description={inviteStatus} + > + {active ? ( + + ) : null} + +
+ ); + }) + )} + + {latestCreatedInvite ? ( + +
+
+ {latestCreatedInvite.inviteLink} +
+ +
+
+ ) : null} + + + setExpiresInDays(Number(value))} + /> + + + + void handleUpdateMemberFloor( + member, + value as CollabSessionAccessMode + ) + } + /> + + + + {renameSaved ? ( + + {t("cloud.orgManagement.settings.renamed")} + + ) : null} +
+ + {renameError ? ( +
{renameError}
+ ) : null} + + {isOwner ? ( + <> + +
+ + +
+
+ {deleteError ? ( +
+ {deleteError} +
+ ) : null} +
+ + ) : null} + + ); +} diff --git a/src/engines/ChatPanel/panels/CloudOrgPanelView/cloudSessionTableItem.test.ts b/src/engines/ChatPanel/panels/CloudOrgPanelView/cloudSessionTableItem.test.ts new file mode 100644 index 000000000..bf637e3f8 --- /dev/null +++ b/src/engines/ChatPanel/panels/CloudOrgPanelView/cloudSessionTableItem.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; + +import { + COLLAB_IDENTITY_KIND, + COLLAB_SESSION_ACCESS_MODE, + type RemoteTeammateSessionMetadata, +} from "@src/store/collaboration/types"; + +import { toCloudSessionTableItem } from "./cloudSessionTableItem"; + +const LABELS = { + fullReplay: "Full replay", + metadataOnly: "Metadata only", + notPublished: "Not published", +}; + +function remoteSession( + overrides: Partial = {} +): RemoteTeammateSessionMetadata { + return { + id: "org-1:user-2:session-1", + orgId: "org-1", + ownerMemberId: "member-2", + ownerUserId: "user-2", + ownerDisplayName: "Taylor", + ownerIdentityKind: COLLAB_IDENTITY_KIND.HUMAN, + sourceSessionId: "session-1", + title: "Review the release", + repoScopeKey: "github.com/acme/orgii", + cliAgentType: "codex", + agentDisplayName: "Codex", + model: "gpt-5.2", + accessMode: COLLAB_SESSION_ACCESS_MODE.FULL_REPLAY, + eventsEpoch: 1, + eventsFrozenSeq: 12, + eventsCount: 14, + eventsTailHash: "tail-hash", + ...overrides, + }; +} + +describe("toCloudSessionTableItem", () => { + it("maps replayable cloud metadata into the shared session table shape", () => { + const item = toCloudSessionTableItem(remoteSession(), LABELS); + + expect(item).toMatchObject({ + id: "org-1:user-2:session-1", + title: "Review the release", + description: "Taylor", + ownerLabel: "Taylor", + agentLabel: "Codex", + workspaceLabel: "github.com/acme/orgii", + workspaceTitle: "github.com/acme/orgii", + statusLabel: "Full replay", + disabled: false, + testId: "cloud-org-session-row", + }); + expect(item.modelLabel).toBeTruthy(); + expect(item.dataAttributes).toEqual({ + "data-cloud-session-id": "session-1", + "data-cloud-session-owner-id": "user-2", + "data-cloud-session-access-mode": "full_replay", + }); + }); + + it("keeps metadata-only sessions visible but disables replay", () => { + const item = toCloudSessionTableItem( + remoteSession({ + accessMode: COLLAB_SESSION_ACCESS_MODE.METADATA_ONLY, + eventsEpoch: undefined, + eventsFrozenSeq: undefined, + eventsCount: undefined, + eventsTailHash: undefined, + }), + LABELS + ); + + expect(item.statusLabel).toBe("Metadata only"); + expect(item.disabled).toBe(true); + }); +}); diff --git a/src/engines/ChatPanel/panels/CloudOrgPanelView/cloudSessionTableItem.ts b/src/engines/ChatPanel/panels/CloudOrgPanelView/cloudSessionTableItem.ts new file mode 100644 index 000000000..1136f5be2 --- /dev/null +++ b/src/engines/ChatPanel/panels/CloudOrgPanelView/cloudSessionTableItem.ts @@ -0,0 +1,61 @@ +import type { SessionTableItem } from "@src/modules/shared/layouts/blocks"; +import { + COLLAB_SESSION_ACCESS_MODE, + type RemoteTeammateSessionMetadata, +} from "@src/store/collaboration/types"; +import { + type FormatSmartDateTimeOptions, + formatSmartDateTime, +} from "@src/util/data/formatters/date"; +import { formatModelNameFull } from "@src/util/formatModelName"; + +export interface CloudSessionTableLabels { + fullReplay: string; + metadataOnly: string; + notPublished: string; +} + +export function toCloudSessionTableItem( + session: RemoteTeammateSessionMetadata, + labels: CloudSessionTableLabels, + dateTimeOptions?: FormatSmartDateTimeOptions +): SessionTableItem { + const replayable = session.eventsEpoch !== undefined; + const metadataOnly = + session.accessMode === COLLAB_SESSION_ACCESS_MODE.METADATA_ONLY; + const statusLabel = metadataOnly + ? labels.metadataOnly + : replayable + ? labels.fullReplay + : labels.notPublished; + const statusColor = metadataOnly + ? "var(--color-text-4)" + : replayable + ? "var(--color-success-6)" + : "var(--color-warning-6)"; + const workspace = session.repoScopeKey ?? session.repoPath; + + return { + id: session.id, + title: session.title, + description: session.ownerDisplayName, + statusLabel, + statusColor, + ownerLabel: session.ownerDisplayName, + agentLabel: session.agentDisplayName ?? session.cliAgentType, + modelLabel: session.model ? formatModelNameFull(session.model) : undefined, + workspaceLabel: workspace, + workspaceTitle: workspace, + lastUpdatedLabel: formatSmartDateTime( + session.lastActivityAt, + dateTimeOptions + ), + disabled: !replayable, + testId: "cloud-org-session-row", + dataAttributes: { + "data-cloud-session-id": session.sourceSessionId, + "data-cloud-session-owner-id": session.ownerUserId, + "data-cloud-session-access-mode": session.accessMode, + }, + }; +} diff --git a/src/engines/ChatPanel/panels/CloudOrgPanelView/index.tsx b/src/engines/ChatPanel/panels/CloudOrgPanelView/index.tsx new file mode 100644 index 000000000..6275e127e --- /dev/null +++ b/src/engines/ChatPanel/panels/CloudOrgPanelView/index.tsx @@ -0,0 +1,1009 @@ +/** + * Panel for a managed ORG2 Cloud org. + * + * Managed-backend counterpart to `CollabOrgPanelView` (self-hosted). Shows + * plan/entitlement (`get_entitlement_state`) and members + * (`list_org_members`) fetched straight from the ORG2 Cloud client — it + * never touches `collabOrgsAtom` or the CollabSyncEngine. + * + * Phase 6 additions: repo scope editing (admin-only, `cloud_set_org_repo_ + * scopes` + the local `org2CloudRepoScopesAtom` mirror that drives the + * desktop push engine). + * + * Org-management closed loop (migration 0010, self-hosted parity): invites + * (create/list/revoke with a one-time copyable link), member role changes / + * removal, leave, rename, ownership transfer and org deletion — state and + * handlers in `useCloudOrgManagement`, sections in `ManagementSections`. + * + * Shared sessions render both in a full-width management tab and in the left + * sidebar's fork-threaded groups. Both surfaces reuse the same remote-session + * cache and `useCloudSessionActions` replay/fork behavior. + */ +import { useAtom, useAtomValue, useSetAtom } from "jotai"; +import { Cloud, Laptop } from "lucide-react"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useTranslation } from "react-i18next"; + +import Button from "@src/components/Button"; +import Select from "@src/components/Select"; +import TabPill, { type TabPillItem } from "@src/components/TabPill"; +import { + floorAccessMode, + getCloudOrgAccessSettings, + getOrgSharingFloor, + isAccessModeAtLeast, + org2CloudAccessSettingsAtom, + org2CloudSharingFloorAtom, + withCloudOrgDefaultMode, +} from "@src/features/Org2Cloud/org2CloudAccessSettings"; +import { + commitRefreshedAuth, + org2CloudAuthAtom, +} from "@src/features/Org2Cloud/org2CloudAuthAtom"; +import { + type CloudEntitlementState, + type CloudOrgMember, + ensureFreshSession, + getEntitlementState, + listOrgMembers, +} from "@src/features/Org2Cloud/org2CloudClient"; +import { + org2CloudOrgsAtom, + org2CloudRosterVersionAtom, +} from "@src/features/Org2Cloud/org2CloudOrgsAtom"; +import { + deriveScopeQuotaView, + parseScopeCooldownFreesAt, +} from "@src/features/Org2Cloud/org2CloudScopeQuota"; +import { org2CloudRepoScopesAtom } from "@src/features/Org2Cloud/org2CloudSyncAtoms"; +import { + type CloudOrgScopeState, + getOrgRepoScopes, + isOrg2SyncErrorCode, + setOrgRepoScopes, + setOrgSharingFloor, +} from "@src/features/Org2Cloud/org2CloudSyncClient"; +import { org2CloudSyncEngine } from "@src/features/Org2Cloud/org2CloudSyncEngine"; +import { useOpenCloudBilling } from "@src/features/Org2Cloud/useOpenCloudBilling"; +import RepoScopePicker from "@src/features/TeamCollaboration/components/RepoScopePicker"; +import { createLogger } from "@src/hooks/logger"; +import { useTauriListen } from "@src/hooks/platform/useTauriListen"; +import { + SECTION_CONTROL_STYLE, + SECTION_DESCRIPTION_CLASSES, + SectionContainer, + SectionRow, +} from "@src/modules/shared/layouts/SectionLayout"; +import { + DETAIL_PANEL_TOKENS, + InternalHeader, + ScrollFadeContainer, +} from "@src/modules/shared/layouts/blocks"; +import { Placeholder } from "@src/modules/shared/layouts/blocks/Placeholder"; +import { + openCloudOrgManagementInChatPanelTabAtom, + openWorkspaceOverviewInChatPanelTabAtom, +} from "@src/store/chatPanel/chatPanelTabsAtom"; +import { COLLAB_SESSION_ACCESS_MODE } from "@src/store/collaboration/types"; +import type { CollabSessionAccessMode } from "@src/store/collaboration/types"; +import { reposAtom } from "@src/store/repo"; +import { + type ChatPanelSelectedCloudOrg, + WORKSPACE_OVERVIEW_TAB, +} from "@src/store/ui/chatPanelAtom"; +import { savedWorkspacesAtom } from "@src/store/ui/workspaceFoldersAtom"; +import { isTauriReady } from "@src/util/platform/tauri/init"; + +import CloudSessionsSection from "./CloudSessionsSection"; +import { + CloudInvitesCard, + CloudMembersSection, + CloudOrgSettingsSection, +} from "./ManagementSections"; +import { + buildCloudOrgSelectorValue, + buildLocalRepoSelectorValue, + buildLocalWorkspaceSelectorValue, + parseManagementTarget, +} from "./managementTargetSelector"; +import { useCloudOrgManagement } from "./useCloudOrgManagement"; + +const log = createLogger("CloudOrgPanelView"); + +interface CloudOrgPanelViewProps { + selectedCloudOrg: ChatPanelSelectedCloudOrg; +} + +type FetchState = "loading" | "ready" | "error"; +type CloudOrgManagementTab = "general" | "sessions" | "repo-scope" | "members"; + +const CLOUD_ORG_MANAGEMENT_TAB = { + GENERAL: "general", + SESSIONS: "sessions", + REPO_SCOPE: "repo-scope", + MEMBERS: "members", +} as const satisfies Record; + +export const CloudOrgPanelView: React.FC = ({ + selectedCloudOrg, +}) => { + const { t } = useTranslation("navigation"); + const { t: tSettings } = useTranslation("settings"); + // Billing lives on the cloud web app; this is the desktop's durable + // upgrade surface (the sync engine's quota toast points users here — the + // engine itself is React-free and cannot render an action button). + const openCloudBillingPage = useOpenCloudBilling(); + const [auth, setAuth] = useAtom(org2CloudAuthAtom); + const cloudOrgs = useAtomValue(org2CloudOrgsAtom); + const localRepos = useAtomValue(reposAtom); + const localWorkspaces = useAtomValue(savedWorkspacesAtom); + const openCloudOrgManagementTab = useSetAtom( + openCloudOrgManagementInChatPanelTabAtom + ); + const openWorkspaceOverviewTab = useSetAtom( + openWorkspaceOverviewInChatPanelTabAtom + ); + const [activeTab, setActiveTab] = useState( + CLOUD_ORG_MANAGEMENT_TAB.GENERAL + ); + // Live roster invalidation: the Realtime org-wide membership subscription + // bumps this per-org counter (useOrg2CloudRealtime); keying the fetch on + // it makes a teammate's join/leave/role-change appear without re-opening + // the panel. + const rosterVersionByOrg = useAtomValue(org2CloudRosterVersionAtom); + const [entitlement, setEntitlement] = useState( + null + ); + const [members, setMembers] = useState([]); + const [fetchState, setFetchState] = useState("loading"); + const [repoScopesByOrg, setRepoScopesByOrg] = useAtom( + org2CloudRepoScopesAtom + ); + // Access ladder (§13.4): per-org DEFAULT sync level for repo-scope-matched + // sessions. Local persisted setting — per-session overrides are edited + // from each session's context menu (CloudSyncLevelDialog). + const [accessByOrg, setAccessByOrg] = useAtom(org2CloudAccessSettingsAtom); + // Admin sharing floor (org policy). The atom mirrors the server truth + // (hydrated from get_entitlement_state below); admins mutate it via the RPC. + const [floorByOrg, setFloorByOrg] = useAtom(org2CloudSharingFloorAtom); + const [savingFloor, setSavingFloor] = useState(false); + const [floorError, setFloorError] = useState(null); + const [scopeState, setScopeState] = useState(null); + const [savingScopes, setSavingScopes] = useState(false); + const [scopesSaved, setScopesSaved] = useState(false); + const [scopesError, setScopesError] = useState(null); + // Bumped when a checkout completes in the system browser + // (org2-cloud-billing-complete, re-emitted by useDeepLinkHandler from the + // orgii://billing/complete deep link) so the panel re-pulls + // entitlement/members/scopes and the plan badge flips without reopening + // the panel. + const [refreshNonce, setRefreshNonce] = useState(0); + useTauriListen( + "org2-cloud-billing-complete", + () => { + log.info("billing checkout completed — refreshing org panel"); + setRefreshNonce((n) => n + 1); + }, + { enabled: isTauriReady() } + ); + + const org = cloudOrgs.find((o) => o.orgId === selectedCloudOrg.orgId); + const orgId = selectedCloudOrg.orgId; + const rosterVersion = rosterVersionByOrg[orgId] ?? 0; + const signedIn = Boolean(auth); + const isAdmin = org?.role === "admin" || org?.role === "owner"; + const isOwner = org?.role === "owner"; + const currentUserId = auth?.userId ?? null; + const savedScopes = useMemo( + () => repoScopesByOrg[orgId] ?? [], + [repoScopesByOrg, orgId] + ); + const [draftScopes, setDraftScopes] = useState(savedScopes); + // Re-seed the draft when the panel switches orgs. + useEffect(() => { + setDraftScopes(repoScopesByOrg[orgId] ?? []); + setScopeState(null); + setScopesSaved(false); + setScopesError(null); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [orgId]); + const scopesDirty = useMemo( + () => + savedScopes.length !== draftScopes.length || + savedScopes.some((scope, index) => scope !== draftScopes[index]), + [savedScopes, draftScopes] + ); + // Dirty flag via ref so the async org-load effect can check it at resolve + // time (same idiom as authRef below) without retriggering on edits. + const scopesDirtyRef = useRef(scopesDirty); + useEffect(() => { + scopesDirtyRef.current = scopesDirty; + }, [scopesDirty]); + // Quota view derives from server occupancy (`used` counts cooling slots + // too, so it may exceed the visible scope list). + const scopeQuota = useMemo( + () => + scopeState + ? deriveScopeQuotaView({ scopeState, draft: draftScopes }) + : null, + [scopeState, draftScopes] + ); + // Latest auth via ref so the token-refresh write inside the effect does + // not retrigger the fetch (effect keys on orgId + signed-in flag only). + const authRef = useRef(auth); + useEffect(() => { + authRef.current = auth; + }, [auth]); + + // Org-management state + handlers (invites / members / settings). + const management = useCloudOrgManagement({ + orgId, + orgName: org?.name ?? "", + isAdmin, + isOwner, + members, + setMembers, + }); + + useEffect(() => { + if (!signedIn) return; + let cancelled = false; + void (async () => { + setFetchState("loading"); + const current = authRef.current; + if (!current) { + if (!cancelled) setFetchState("error"); + return; + } + const fresh = await ensureFreshSession(current); + if (!fresh) { + log.warn("cloud org panel fetch skipped: token refresh failed"); + if (!cancelled) setFetchState("error"); + return; + } + commitRefreshedAuth(setAuth, current, fresh); + const [entitlementResult, membersResult, scopeStateResult] = + await Promise.all([ + getEntitlementState(fresh.accessToken, orgId), + listOrgMembers(fresh.accessToken, orgId), + // Scope governance is best-effort: the panel still renders (with + // the local mirror) when the RPC fails. + getOrgRepoScopes(fresh.accessToken, orgId).catch((error: unknown) => { + log.warn("cloud_get_org_repo_scopes failed:", error); + return null; + }), + ]); + if (cancelled) return; + setEntitlement(entitlementResult); + // Hydrate the local sharing-floor mirror from server truth (only when + // the fetch succeeded — a null result must not clobber the last-known + // floor the push engine relies on). + if (entitlementResult) { + const nextFloor = + entitlementResult.orgSharingFloor ?? COLLAB_SESSION_ACCESS_MODE.OFF; + setFloorByOrg((prev) => + prev[orgId] === nextFloor ? prev : { ...prev, [orgId]: nextFloor } + ); + } + setMembers(membersResult); + if (scopeStateResult) { + // Server truth replaces the local mirror (cross-device hydration) + // and re-seeds the draft — but ONLY when the admin has no in-flight + // edits (a dirty draft must never be clobbered by a slow fetch). + setScopeState(scopeStateResult); + setRepoScopesByOrg((prev) => ({ + ...prev, + [orgId]: scopeStateResult.repoScopes, + })); + if (!scopesDirtyRef.current) { + setDraftScopes(scopeStateResult.repoScopes); + } + } + // Entitlement is a best-effort plan badge — a transient + // `get_entitlement_state` failure (it returns null on ANY error) must NOT + // blank the whole panel (members / invites / repo scopes / leave / + // transfer / delete). Treat the fetch as ready as long as SOMETHING + // loaded; a viewable signed-in org always has >=1 member, so an empty + // members list alongside a null entitlement means the whole fetch failed + // (keep the clean full-panel error for that case). + setFetchState( + entitlementResult || membersResult.length > 0 ? "ready" : "error" + ); + })(); + return () => { + cancelled = true; + }; + }, [ + orgId, + signedIn, + refreshNonce, + setAuth, + setRepoScopesByOrg, + setFloorByOrg, + ]); + + // A roster signal only invalidates the member list. Keeping it out of the + // full panel effect prevents every teammate role/join/leave event from also + // re-reading entitlement and repo scopes. + const observedRosterVersionRef = useRef(rosterVersion); + useEffect(() => { + observedRosterVersionRef.current = rosterVersion; + // Only reset the watermark when switching orgs. `rosterVersion` is read + // deliberately but omitted so a normal bump reaches the fetch effect. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [orgId]); + useEffect(() => { + if (!signedIn) return; + if (observedRosterVersionRef.current === rosterVersion) return; + observedRosterVersionRef.current = rosterVersion; + let cancelled = false; + void (async () => { + const current = authRef.current; + if (!current) return; + const fresh = await ensureFreshSession(current); + if (!fresh || cancelled) return; + commitRefreshedAuth(setAuth, current, fresh); + const nextMembers = await listOrgMembers(fresh.accessToken, orgId); + if (!cancelled) setMembers(nextMembers); + })().catch((error: unknown) => { + log.warn("cloud org roster refresh failed:", error); + }); + return () => { + cancelled = true; + }; + }, [orgId, signedIn, rosterVersion, setAuth]); + + // Re-read quota/cooling after a save attempt — a save changes occupancy + // (success) or reveals a cooling slot (ORG2_SCOPE_COOLDOWN). + const refreshScopeState = async (accessToken: string): Promise => { + try { + const state = await getOrgRepoScopes(accessToken, orgId); + setScopeState(state); + setRepoScopesByOrg((prev) => ({ ...prev, [orgId]: state.repoScopes })); + } catch (error) { + log.warn("cloud_get_org_repo_scopes refresh failed:", error); + } + }; + + const handleSaveScopes = async (): Promise => { + const current = authRef.current; + if (!current) return; + setSavingScopes(true); + setScopesError(null); + setScopesSaved(false); + // Captured for the catch: authRef may still hold the pre-refresh token + // when the save rejects (ORG2_SCOPE_COOLDOWN refetch below). + let freshToken: string | null = null; + try { + const fresh = await ensureFreshSession(current); + if (!fresh) throw new Error(t("cloud.orgPanel.loadError")); + commitRefreshedAuth(setAuth, current, fresh); + freshToken = fresh.accessToken; + await setOrgRepoScopes(fresh.accessToken, orgId, draftScopes); + // Local mirror drives the desktop push engine (server truth re-read + // right below). + setRepoScopesByOrg((prev) => ({ ...prev, [orgId]: draftScopes })); + setScopesSaved(true); + await refreshScopeState(fresh.accessToken); + } catch (error) { + if (isOrg2SyncErrorCode(error, "ORG2_SCOPE_COOLDOWN")) { + const freesAt = parseScopeCooldownFreesAt( + error instanceof Error ? error.message : "" + ); + setScopesError( + freesAt + ? t("cloud.orgPanel.scopeCooldownError", { + date: freesAt.toLocaleDateString(), + }) + : t("cloud.orgPanel.scopeCooldownErrorNoDate") + ); + // The rejected save still tells us a slot is cooling — pick up its + // frees-at for the greyed rows, using the token freshened for the + // save (authRef may lag behind the refresh). + if (freshToken) await refreshScopeState(freshToken); + return; + } + setScopesError(error instanceof Error ? error.message : String(error)); + } finally { + setSavingScopes(false); + } + }; + + // Admin sharing FLOOR for this org (0002 policy). Members can't set a + // per-device default (or per-session override) below it. + const orgFloor = getOrgSharingFloor(floorByOrg, orgId); + + // Default sync level (access ladder §13.4). Options reuse the shared + // ladder labels; the value writes straight to the persisted settings atom + // and a drained sync pass is kicked so upgrades, downgrades, and retractions + // are applied promptly. Modes below the org floor are dropped — the + // engine floors them anyway, so offering them would only mislead. + const defaultAccessMode = getCloudOrgAccessSettings( + accessByOrg, + orgId + ).defaultMode; + const accessModeOptions = useMemo( + () => + [ + { + value: COLLAB_SESSION_ACCESS_MODE.OFF, + label: t("cloud.syncLevel.modeOff"), + dataTestId: "cloud-org-default-access-off", + }, + { + value: COLLAB_SESSION_ACCESS_MODE.METADATA_ONLY, + label: t("cloud.syncLevel.modeMetadata"), + dataTestId: "cloud-org-default-access-metadata", + }, + { + value: COLLAB_SESSION_ACCESS_MODE.FULL_REPLAY, + label: t("cloud.syncLevel.modeFullReplay"), + dataTestId: "cloud-org-default-access-full", + }, + ].filter((option) => isAccessModeAtLeast(option.value, orgFloor)), + [t, orgFloor] + ); + const handleDefaultAccessChange = useCallback( + (value: string | number | (string | number)[]) => { + setAccessByOrg((current) => + withCloudOrgDefaultMode( + current, + orgId, + value as CollabSessionAccessMode + ) + ); + void org2CloudSyncEngine.runSyncPassAndWaitForDrain(); + }, + [orgId, setAccessByOrg] + ); + + // Admin-only: options for the org sharing FLOOR select. 'off' means "no + // minimum" (members choose freely, today's behaviour). + const floorOptions = useMemo( + () => [ + { + value: COLLAB_SESSION_ACCESS_MODE.OFF, + label: t("cloud.sharingFloor.optionNone"), + dataTestId: "cloud-org-sharing-floor-off", + }, + { + value: COLLAB_SESSION_ACCESS_MODE.METADATA_ONLY, + label: t("cloud.syncLevel.modeMetadata"), + dataTestId: "cloud-org-sharing-floor-metadata", + }, + { + value: COLLAB_SESSION_ACCESS_MODE.FULL_REPLAY, + label: t("cloud.syncLevel.modeFullReplay"), + dataTestId: "cloud-org-sharing-floor-full", + }, + ], + [t] + ); + // Admin sets the org floor: optimistic mirror write, RPC, then revert + + // surface the error on failure (e.g. ORG2_ADMIN_REQUIRED if they just lost + // admin). A sync pass applies the new floor to this device's pushes at once. + const handleFloorChange = async ( + value: string | number | (string | number)[] + ): Promise => { + const next = value as CollabSessionAccessMode; + const previous = orgFloor; + if (next === previous) return; + const current = authRef.current; + if (!current) return; + setFloorError(null); + setSavingFloor(true); + setFloorByOrg((prev) => ({ ...prev, [orgId]: next })); + try { + const fresh = await ensureFreshSession(current); + if (!fresh) throw new Error(t("cloud.orgPanel.loadError")); + commitRefreshedAuth(setAuth, current, fresh); + await setOrgSharingFloor(fresh.accessToken, orgId, next); + await org2CloudSyncEngine.runSyncPassAndWaitForDrain(); + } catch (error) { + setFloorByOrg((prev) => ({ ...prev, [orgId]: previous })); + setFloorError(error instanceof Error ? error.message : String(error)); + } finally { + setSavingFloor(false); + } + }; + + // Cooling slots render greyed and non-removable in both the admin editor + // and the read-only list — the slot is occupied server-side either way. + const coolingRowsBlock = + scopeQuota && scopeQuota.coolingRows.length > 0 + ? scopeQuota.coolingRows.map((row) => ( +
+ {row.scopeKey}} + truncateLabel + light + > + + {t("cloud.orgPanel.scopeCoolingRow", { days: row.daysLeft })} + + +
+ )) + : null; + + const orgName = org?.name ?? ""; + const managementTargetOptions = useMemo( + () => [ + ...cloudOrgs.map((cloudOrg) => ({ + value: buildCloudOrgSelectorValue(cloudOrg.orgId), + label: cloudOrg.name, + icon: , + dataTestId: `cloud-org-switch-option-${cloudOrg.orgId}`, + })), + ...localWorkspaces.map((workspace) => ({ + value: buildLocalWorkspaceSelectorValue(workspace.workspaceId), + label: workspace.name, + icon: , + dataTestId: `local-workspace-switch-option-${workspace.workspaceId}`, + })), + ...localRepos.map((repo) => ({ + value: buildLocalRepoSelectorValue(repo.id), + label: repo.name || repo.path?.split("/").pop() || t("workspace"), + icon: , + dataTestId: `local-repo-switch-option-${repo.id}`, + })), + ], + [cloudOrgs, localRepos, localWorkspaces, t] + ); + const managementTabs = useMemo( + () => [ + { + key: CLOUD_ORG_MANAGEMENT_TAB.GENERAL, + label: tSettings("sections.general"), + dataTestId: "cloud-org-tab-general", + }, + { + key: CLOUD_ORG_MANAGEMENT_TAB.SESSIONS, + label: t("routes.sessions"), + dataTestId: "cloud-org-tab-sessions", + }, + { + key: CLOUD_ORG_MANAGEMENT_TAB.REPO_SCOPE, + label: t("cloud.orgPanel.repoScopesTitle"), + dataTestId: "cloud-org-tab-repo-scope", + }, + { + key: CLOUD_ORG_MANAGEMENT_TAB.MEMBERS, + label: t("cloud.orgPanel.membersTitle"), + dataTestId: "cloud-org-tab-members", + }, + ], + [t, tSettings] + ); + const handleOrgChange = useCallback( + (value: string | number | (string | number)[]) => { + if (Array.isArray(value)) return; + const selectorValue = String(value); + const target = parseManagementTarget(selectorValue); + if (!target) return; + + if (target.kind === "cloud-org") { + if (target.id === orgId) return; + openCloudOrgManagementTab({ + cloudOrg: { orgId: target.id }, + title: t("collaboration.manageOrg"), + }); + return; + } + + if (target.kind === "local-repo") { + const repo = localRepos.find((candidate) => candidate.id === target.id); + if (!repo) return; + openWorkspaceOverviewTab({ + workspace: { + kind: "repo", + id: repo.id, + name: repo.name || repo.path?.split("/").pop() || t("workspace"), + path: repo.path, + }, + tab: WORKSPACE_OVERVIEW_TAB.DETAILS, + }); + return; + } + + const workspace = localWorkspaces.find( + (candidate) => candidate.workspaceId === target.id + ); + if (!workspace) return; + const primaryFolder = + workspace.folders.find((folder) => folder.isPrimary) ?? + workspace.folders[0]; + openWorkspaceOverviewTab({ + workspace: { + kind: "workspace", + id: workspace.workspaceId, + name: workspace.name, + path: primaryFolder?.folderPath, + folderCount: workspace.folders.length, + repoIds: workspace.folders.flatMap((folder) => + folder.repoId ? [folder.repoId] : [] + ), + }, + tab: WORKSPACE_OVERVIEW_TAB.OVERVIEW, + }); + }, + [ + localRepos, + localWorkspaces, + openCloudOrgManagementTab, + openWorkspaceOverviewTab, + orgId, + t, + ] + ); + // Signed out (e.g. sign-out while the panel is open) renders as an error + // state without any effect-driven state write. + const viewState: FetchState = signedIn ? fetchState : "error"; + + return ( +
+ + void handleFloorChange(value)} + size="default" + style={SECTION_CONTROL_STYLE} + disabled={savingFloor} + dataTestId="cloud-org-sharing-floor-select" + /> + {floorError ? ( + + {floorError} + + ) : null} +
+ + ) : orgFloor !== COLLAB_SESSION_ACCESS_MODE.OFF ? ( + + {t("cloud.sharingFloor.label")} +
+ } + description={t("cloud.sharingFloor.memberNote", { + mode: + orgFloor === COLLAB_SESSION_ACCESS_MODE.FULL_REPLAY + ? t("cloud.syncLevel.modeFullReplay") + : t("cloud.syncLevel.modeMetadata"), + })} + /> + ) : null} + + {/* Default per-session sync level for THIS device's sessions + in this org (access ladder §13.4). Repo scopes pick + candidates; this default gates uploads. */} + +
+ { - if (event.key === "Enter" && !event.shiftKey) { - event.preventDefault(); - void onSendMessage(); - } - }} - /> - -
- )} -
- - ); -} diff --git a/src/engines/ChatPanel/panels/CollabOrgPanelView/MembersSection.tsx b/src/engines/ChatPanel/panels/CollabOrgPanelView/MembersSection.tsx deleted file mode 100644 index 27a050206..000000000 --- a/src/engines/ChatPanel/panels/CollabOrgPanelView/MembersSection.tsx +++ /dev/null @@ -1,233 +0,0 @@ -import type { TFunction } from "i18next"; -import React from "react"; - -import Button from "@src/components/Button"; -import { SectionContainer } from "@src/modules/shared/layouts/SectionLayout"; -import { COLLAB_ROLE } from "@src/store/collaboration/types"; -import type { - CollabInviteRecord, - CollabMemberRecord, - CollabOrgRecord, -} from "@src/store/collaboration/types"; - -import { formatSessionDate, getInviteRemainingUses } from "./utils"; - -function MemberStatusPill({ - active, - label, -}: { - active: boolean; - label: string; -}): React.ReactNode { - return ( - - - {label} - - ); -} - -interface InviteCardProps { - t: TFunction<"navigation">; - latestInvite: CollabInviteRecord | undefined; - canCreateInvite: boolean; - creatingInvite: boolean; - copyingInvite: boolean; - inviteError: string | null; - onCreateInvite: () => void; - onCopyInvite: () => void; -} - -function InviteCard({ - t, - latestInvite, - canCreateInvite, - creatingInvite, - copyingInvite, - inviteError, - onCreateInvite, - onCopyInvite, -}: InviteCardProps) { - return ( - -
-
-
- {t("collaboration.invite.title")} -
- {!canCreateInvite ? ( -
- {t("collaboration.invite.adminOnly")} -
- ) : null} - {latestInvite ? ( -
-
- {latestInvite.inviteLink} -
-
- {t("collaboration.invite.remainingUses", { - count: getInviteRemainingUses(latestInvite), - })} - {latestInvite.expiresAt - ? ` · ${t("collaboration.invite.expires", { - date: formatSessionDate(latestInvite.expiresAt), - })}` - : ""} -
-
- ) : null} - {inviteError ? ( -
{inviteError}
- ) : null} -
-
- {latestInvite ? ( - - ) : null} - -
-
-
- ); -} - -interface MembersSectionProps { - t: TFunction<"navigation">; - org: CollabOrgRecord; - orgMembers: CollabMemberRecord[]; - selectedMember: CollabMemberRecord | null; - currentMember: CollabMemberRecord | undefined; - activeMemberIds: Set; - latestInvite: CollabInviteRecord | undefined; - canCreateInvite: boolean; - creatingInvite: boolean; - copyingInvite: boolean; - inviteError: string | null; - memberError: string | null; - removingMemberId: string | null; - onCreateInvite: () => void; - onCopyInvite: () => void; - onSelectMember: (member: CollabMemberRecord) => void; - onRemoveMember: (member: CollabMemberRecord) => void; -} - -export function MembersSection({ - t, - org, - orgMembers, - selectedMember, - currentMember, - activeMemberIds, - latestInvite, - canCreateInvite, - creatingInvite, - copyingInvite, - inviteError, - memberError, - removingMemberId, - onCreateInvite, - onCopyInvite, - onSelectMember, - onRemoveMember, -}: MembersSectionProps) { - return ( - <> - {!selectedMember ? ( - - ) : null} - - -
- {memberError ? ( -
- {memberError} -
- ) : null} - {orgMembers.map((member) => { - const canRemoveMember = - currentMember?.role === COLLAB_ROLE.ADMIN && - currentMember.id !== member.id && - Boolean(org.supabaseUrl && org.supabaseAnonKey && org.orgSecret); - const active = activeMemberIds.has(member.id); - return ( -
- - {canRemoveMember ? ( - - ) : null} -
- ); - })} -
-
- - ); -} diff --git a/src/engines/ChatPanel/panels/CollabOrgPanelView/MetadataSections.tsx b/src/engines/ChatPanel/panels/CollabOrgPanelView/MetadataSections.tsx deleted file mode 100644 index 7e78d716b..000000000 --- a/src/engines/ChatPanel/panels/CollabOrgPanelView/MetadataSections.tsx +++ /dev/null @@ -1,179 +0,0 @@ -import type { TFunction } from "i18next"; -import React from "react"; - -import { SectionContainer } from "@src/modules/shared/layouts/SectionLayout"; - -import { getMetadataId, getRecordField, getStringField } from "./utils"; - -interface MetadataSectionHeaderProps { - title: string; - description: string; - countLabel: string; -} - -function MetadataSectionHeader({ - title, - description, - countLabel, -}: MetadataSectionHeaderProps) { - return ( -
-
-
{title}
-
{description}
-
-
- {countLabel} -
-
- ); -} - -function MetadataError({ error }: { error: string | null }) { - if (!error) return null; - return ( -
- {error} -
- ); -} - -interface WorkItemsSectionProps { - t: TFunction<"navigation">; - workItems: Record[]; - localMetadataError: string | null; -} - -export function WorkItemsSection({ - t, - workItems, - localMetadataError, -}: WorkItemsSectionProps) { - return ( - -
- - - {workItems.length === 0 ? ( -
- {t("collaboration.workItems.empty")} -
- ) : ( -
- {workItems.map((workItem, index) => { - const project = getRecordField(workItem, "project"); - const projectName = project - ? getStringField(project, ["name"]) - : getStringField(workItem, ["projectName", "projectId"]); - return ( -
-
-
-
- {getStringField(workItem, ["title", "name"])} -
-
- {projectName} -
-
-
- {getStringField(workItem, ["status"])} -
-
-
- - {getStringField(workItem, ["priority"])} - - - {getStringField( - workItem, - ["assigneeName"], - t("collaboration.workItems.unassigned") - )} - -
-
- ); - })} -
- )} -
-
- ); -} - -interface ProjectsSectionProps { - t: TFunction<"navigation">; - projects: Record[]; - localMetadataError: string | null; -} - -export function ProjectsSection({ - t, - projects, - localMetadataError, -}: ProjectsSectionProps) { - return ( - -
- - - {projects.length === 0 ? ( -
- {t("collaboration.projects.empty")} -
- ) : ( -
- {projects.map((project, index) => ( -
-
-
-
- {getStringField(project, ["name", "title"])} -
-
- {getStringField( - project, - ["description"], - t("collaboration.projects.noDescription") - )} -
-
-
- {getStringField(project, ["status"])} -
-
-
- - {getStringField(project, ["priority"])} - - - {getStringField(project, ["health"])} - -
-
- ))} -
- )} -
-
- ); -} diff --git a/src/engines/ChatPanel/panels/CollabOrgPanelView/SessionsSection.tsx b/src/engines/ChatPanel/panels/CollabOrgPanelView/SessionsSection.tsx deleted file mode 100644 index e92f83802..000000000 --- a/src/engines/ChatPanel/panels/CollabOrgPanelView/SessionsSection.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import type { TFunction } from "i18next"; -import React from "react"; - -import { SessionTable } from "@src/modules/shared/layouts/blocks"; -import type { SessionTableItem } from "@src/modules/shared/layouts/blocks"; -import type { CollabSessionSnapshotRequestRecord } from "@src/store/collaboration/types"; - -import { COLLAB_SNAPSHOT_REQUEST_STATUS } from "./constants"; - -interface SessionsSectionProps { - t: TFunction<"navigation">; - sessionItems: SessionTableItem[]; - latestSnapshotRequest: CollabSessionSnapshotRequestRecord | undefined; - onSelectSession: (item: SessionTableItem) => void; -} - -export function SessionsSection({ - t, - sessionItems, - latestSnapshotRequest, - onSelectSession, -}: SessionsSectionProps) { - const showPendingMessage = - latestSnapshotRequest?.status === COLLAB_SNAPSHOT_REQUEST_STATUS.PENDING || - latestSnapshotRequest?.status === COLLAB_SNAPSHOT_REQUEST_STATUS.SENT; - - return ( - <> - - {showPendingMessage ? ( -
- {t("collaboration.access.requestPending")} -
- ) : null} - {latestSnapshotRequest?.error ? ( -
- {latestSnapshotRequest.error} -
- ) : null} - - ); -} diff --git a/src/engines/ChatPanel/panels/CollabOrgPanelView/SettingsSection.tsx b/src/engines/ChatPanel/panels/CollabOrgPanelView/SettingsSection.tsx deleted file mode 100644 index 562ad2623..000000000 --- a/src/engines/ChatPanel/panels/CollabOrgPanelView/SettingsSection.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import type { TFunction } from "i18next"; -import React from "react"; - -import { SectionContainer } from "@src/modules/shared/layouts/SectionLayout"; -import { COLLAB_SESSION_ACCESS_MODE } from "@src/store/collaboration/types"; -import type { - CollabSessionAccessMode, - CollabSessionAccessSettings, -} from "@src/store/collaboration/types"; - -const ACCESS_MODES = [ - COLLAB_SESSION_ACCESS_MODE.OFF, - COLLAB_SESSION_ACCESS_MODE.METADATA_ONLY, - COLLAB_SESSION_ACCESS_MODE.FULL_REPLAY, -] as const; - -interface SettingsSectionProps { - t: TFunction<"navigation">; - currentAccessSettings: CollabSessionAccessSettings | null; - workspaceOptions: string[]; - onSelectAccessMode: (accessMode: CollabSessionAccessMode) => void; - onToggleWorkspace: (workspacePath: string) => void; -} - -export function SettingsSection({ - t, - currentAccessSettings, - workspaceOptions, - onSelectAccessMode, - onToggleWorkspace, -}: SettingsSectionProps) { - return ( - -
-
-
- {t("collaboration.access.title")} -
-
- {t("collaboration.access.description")} -
-
-
- {ACCESS_MODES.map((accessMode) => ( - - ))} -
-
-
- {t("collaboration.access.workspaces")} -
-
- {t("collaboration.access.workspacesHint")} -
-
- {workspaceOptions.length === 0 ? ( -
- {t("collaboration.access.noWorkspaces")} -
- ) : ( - workspaceOptions.map((workspacePath) => { - const selected = - currentAccessSettings?.workspacePaths.includes( - workspacePath - ) ?? false; - return ( - - ); - }) - )} -
-
-
- {t("collaboration.access.fullReplayWarning")} -
-
-
- ); -} diff --git a/src/engines/ChatPanel/panels/CollabOrgPanelView/constants.ts b/src/engines/ChatPanel/panels/CollabOrgPanelView/constants.ts deleted file mode 100644 index 4be5ab524..000000000 --- a/src/engines/ChatPanel/panels/CollabOrgPanelView/constants.ts +++ /dev/null @@ -1,21 +0,0 @@ -export const COLLAB_ORG_TAB = { - WORK_ITEMS: "workItems", - PROJECTS: "projects", - SESSIONS: "sessions", - MEMBERS: "members", - CHAT: "chat", - SETTINGS: "settings", -} as const; - -export type CollabOrgTab = (typeof COLLAB_ORG_TAB)[keyof typeof COLLAB_ORG_TAB]; - -export const CHAT_HISTORY_LIMIT = 100; -export const DEFAULT_INVITE_USAGE_LIMIT = 10; - -export const COLLAB_SNAPSHOT_REQUEST_STATUS = { - PENDING: "pending", - SENT: "sent", - DENIED: "denied", - COMPLETED: "completed", - FAILED: "failed", -} as const; diff --git a/src/engines/ChatPanel/panels/CollabOrgPanelView/useAccessSettingsModel.ts b/src/engines/ChatPanel/panels/CollabOrgPanelView/useAccessSettingsModel.ts deleted file mode 100644 index cd317624f..000000000 --- a/src/engines/ChatPanel/panels/CollabOrgPanelView/useAccessSettingsModel.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { useAtom } from "jotai"; -import { useCallback, useMemo } from "react"; - -import { collabSessionAccessSettingsAtom } from "@src/store/collaboration/collabOrgsAtom"; -import { COLLAB_WORKSPACE_SCOPE } from "@src/store/collaboration/types"; -import type { - CollabMemberRecord, - CollabSessionAccessMode, - CollabSessionAccessSettings, -} from "@src/store/collaboration/types"; -import type { Session } from "@src/store/session"; - -import { createDefaultAccessSettings, normalizeWorkspacePath } from "./utils"; - -interface UseAccessSettingsModelParams { - orgId: string; - currentMember: CollabMemberRecord | undefined; - sessions: Session[]; -} - -export function useAccessSettingsModel({ - orgId, - currentMember, - sessions, -}: UseAccessSettingsModelParams) { - const [accessSettingsList, setAccessSettingsList] = useAtom( - collabSessionAccessSettingsAtom - ); - - const currentAccessSettings = useMemo(() => { - if (!currentMember) return null; - return ( - accessSettingsList.find( - (settings) => - settings.orgId === orgId && settings.memberId === currentMember.id - ) ?? - createDefaultAccessSettings( - orgId, - currentMember.id, - COLLAB_WORKSPACE_SCOPE.SELECTED_WORKSPACES - ) - ); - }, [accessSettingsList, currentMember, orgId]); - - const workspaceOptions = useMemo(() => { - const paths = new Set(); - for (const session of sessions) { - const normalizedPath = normalizeWorkspacePath(session.repoPath); - if (normalizedPath) paths.add(normalizedPath); - } - return Array.from(paths).sort((left, right) => left.localeCompare(right)); - }, [sessions]); - - const updateAccessSettings = useCallback( - ( - updates: Partial< - Pick - > - ) => { - if (!currentMember || !currentAccessSettings) return; - const nextSettings: CollabSessionAccessSettings = { - ...currentAccessSettings, - ...updates, - workspaceScope: COLLAB_WORKSPACE_SCOPE.SELECTED_WORKSPACES, - updatedAt: new Date().toISOString(), - }; - setAccessSettingsList((current) => { - const existingIndex = current.findIndex( - (settings) => - settings.orgId === nextSettings.orgId && - settings.memberId === nextSettings.memberId - ); - if (existingIndex < 0) return [nextSettings, ...current]; - const next = [...current]; - next[existingIndex] = nextSettings; - return next; - }); - }, - [currentAccessSettings, currentMember, setAccessSettingsList] - ); - - const handleSelectAccessMode = useCallback( - (accessMode: CollabSessionAccessMode) => { - updateAccessSettings({ accessMode }); - }, - [updateAccessSettings] - ); - - const handleToggleWorkspace = useCallback( - (workspacePath: string) => { - if (!currentAccessSettings) return; - const workspacePaths = currentAccessSettings.workspacePaths.includes( - workspacePath - ) - ? currentAccessSettings.workspacePaths.filter( - (path) => path !== workspacePath - ) - : [...currentAccessSettings.workspacePaths, workspacePath]; - updateAccessSettings({ workspacePaths }); - }, - [currentAccessSettings, updateAccessSettings] - ); - - return { - currentAccessSettings, - workspaceOptions, - handleSelectAccessMode, - handleToggleWorkspace, - }; -} diff --git a/src/engines/ChatPanel/panels/CollabOrgPanelView/useCollabOrgChat.ts b/src/engines/ChatPanel/panels/CollabOrgPanelView/useCollabOrgChat.ts deleted file mode 100644 index 4abdb972e..000000000 --- a/src/engines/ChatPanel/panels/CollabOrgPanelView/useCollabOrgChat.ts +++ /dev/null @@ -1,134 +0,0 @@ -import type { TFunction } from "i18next"; -import { useAtom } from "jotai"; -import { useCallback, useEffect, useState } from "react"; - -import { supabaseSyncClient } from "@src/features/TeamCollaboration/sync/supabaseSyncClient"; -import { collabChatMessagesAtom } from "@src/store/collaboration/collabOrgsAtom"; -import { COLLAB_IDENTITY_KIND } from "@src/store/collaboration/types"; -import type { - CollabChatMessageRecord, - CollabMemberRecord, - CollabOrgRecord, -} from "@src/store/collaboration/types"; - -import { CHAT_HISTORY_LIMIT } from "./constants"; -import { createLocalChatMessageId, upsertChatMessage } from "./utils"; - -interface UseCollabOrgChatParams { - org: CollabOrgRecord | undefined; - orgMembers: CollabMemberRecord[]; - currentMember: CollabMemberRecord | undefined; - t: TFunction<"navigation">; -} - -export function useCollabOrgChat({ - org, - orgMembers, - currentMember, - t, -}: UseCollabOrgChatParams) { - const [chatMessages, setChatMessages] = useAtom(collabChatMessagesAtom); - const [draftMessage, setDraftMessage] = useState(""); - const [sending, setSending] = useState(false); - const [chatError, setChatError] = useState(null); - - useEffect(() => { - if (!org?.supabaseUrl || !org.supabaseAnonKey || !org.orgSecret) return; - let cancelled = false; - supabaseSyncClient - .listChatMessages({ - supabaseUrl: org.supabaseUrl, - anonKey: org.supabaseAnonKey, - orgSecret: org.orgSecret, - orgId: org.id, - limit: CHAT_HISTORY_LIMIT, - }) - .then((messages) => { - if (cancelled) return; - setChatMessages((current) => - messages.reduce(upsertChatMessage, current) - ); - }) - .catch((error: unknown) => { - if (cancelled) return; - setChatError(error instanceof Error ? error.message : String(error)); - }); - return () => { - cancelled = true; - }; - }, [ - org?.id, - org?.orgSecret, - org?.supabaseAnonKey, - org?.supabaseUrl, - setChatMessages, - ]); - - const handleSendMessage = useCallback(async () => { - const body = draftMessage.trim(); - if (!body || !org || sending) return; - setSending(true); - setChatError(null); - try { - if ( - org.supabaseUrl && - org.supabaseAnonKey && - org.orgSecret && - currentMember - ) { - const message = await supabaseSyncClient.postChatMessage({ - supabaseUrl: org.supabaseUrl, - anonKey: org.supabaseAnonKey, - orgSecret: org.orgSecret, - orgId: org.id, - memberId: currentMember.id, - authorDisplayName: currentMember.displayName, - authorIdentityKind: currentMember.identityKind, - body, - }); - setChatMessages((current) => upsertChatMessage(current, message)); - } else { - const author = - currentMember ?? - orgMembers.find( - (member) => member.identityKind === COLLAB_IDENTITY_KIND.HUMAN - ) ?? - orgMembers[0]; - const message: CollabChatMessageRecord = { - id: createLocalChatMessageId(org.id), - orgId: org.id, - authorMemberId: author?.id ?? "local-human", - authorDisplayName: - author?.displayName ?? t("collaboration.localHuman"), - authorIdentityKind: - author?.identityKind ?? COLLAB_IDENTITY_KIND.HUMAN, - body, - createdAt: new Date().toISOString(), - }; - setChatMessages((current) => upsertChatMessage(current, message)); - } - setDraftMessage(""); - } catch (error) { - setChatError(error instanceof Error ? error.message : String(error)); - } finally { - setSending(false); - } - }, [ - currentMember, - draftMessage, - org, - orgMembers, - sending, - setChatMessages, - t, - ]); - - return { - chatMessages, - draftMessage, - setDraftMessage, - sending, - chatError, - handleSendMessage, - }; -} diff --git a/src/engines/ChatPanel/panels/CollabOrgPanelView/useCollabOrgPanelModel.ts b/src/engines/ChatPanel/panels/CollabOrgPanelView/useCollabOrgPanelModel.ts deleted file mode 100644 index 61d07bb92..000000000 --- a/src/engines/ChatPanel/panels/CollabOrgPanelView/useCollabOrgPanelModel.ts +++ /dev/null @@ -1,240 +0,0 @@ -import { useAtomValue, useSetAtom } from "jotai"; -import { useCallback, useMemo, useState } from "react"; -import { useTranslation } from "react-i18next"; - -import { - collabMembersAtom, - collabOrgsAtom, - collabProjectsAtom, - collabSessionSnapshotRequestsAtom, - collabWorkItemsAtom, - remoteTeammateSessionsAtom, -} from "@src/store/collaboration/collabOrgsAtom"; -import { COLLAB_ROLE } from "@src/store/collaboration/types"; -import type { CollabMemberRecord } from "@src/store/collaboration/types"; -import { sessionsAtom } from "@src/store/session"; -import type { ChatPanelSelectedCollabOrg } from "@src/store/ui/chatPanelAtom"; -import { chatPanelSelectedCollabOrgAtom } from "@src/store/ui/chatPanelAtom"; - -import { COLLAB_ORG_TAB } from "./constants"; -import type { CollabOrgTab } from "./constants"; -import { useAccessSettingsModel } from "./useAccessSettingsModel"; -import { useCollabOrgChat } from "./useCollabOrgChat"; -import { useLocalOrgMetadata } from "./useLocalOrgMetadata"; -import { useMemberActions } from "./useMemberActions"; -import { useSessionActions } from "./useSessionActions"; -import { isToday, toSessionTableItem } from "./utils"; - -export function useCollabOrgPanelModel( - selectedCollabOrg: ChatPanelSelectedCollabOrg -) { - const { t } = useTranslation("navigation"); - const orgs = useAtomValue(collabOrgsAtom); - const sessions = useAtomValue(sessionsAtom); - const remoteSessions = useAtomValue(remoteTeammateSessionsAtom); - const members = useAtomValue(collabMembersAtom); - const projects = useAtomValue(collabProjectsAtom); - const workItems = useAtomValue(collabWorkItemsAtom); - const snapshotRequests = useAtomValue(collabSessionSnapshotRequestsAtom); - const setSelectedCollabOrg = useSetAtom(chatPanelSelectedCollabOrgAtom); - const [activeTab, setActiveTab] = useState( - selectedCollabOrg.memberId - ? COLLAB_ORG_TAB.MEMBERS - : COLLAB_ORG_TAB.WORK_ITEMS - ); - - const org = useMemo( - () => orgs.find((candidate) => candidate.id === selectedCollabOrg.orgId), - [orgs, selectedCollabOrg.orgId] - ); - - const orgMembers = useMemo( - () => - members.filter( - (member) => - member.orgId === selectedCollabOrg.orgId && !member.removedAt - ), - [members, selectedCollabOrg.orgId] - ); - - const selectedMember = useMemo( - () => - selectedCollabOrg.memberId - ? (orgMembers.find( - (member) => member.id === selectedCollabOrg.memberId - ) ?? null) - : null, - [orgMembers, selectedCollabOrg.memberId] - ); - - const currentMember = useMemo( - () => - orgMembers.find((member) => member.id === org?.localMemberId) ?? - orgMembers.find((member) => member.role === COLLAB_ROLE.ADMIN) ?? - orgMembers[0], - [org?.localMemberId, orgMembers] - ); - - const accessSettingsModel = useAccessSettingsModel({ - orgId: selectedCollabOrg.orgId, - currentMember, - sessions, - }); - const chatModel = useCollabOrgChat({ org, orgMembers, currentMember, t }); - const memberActions = useMemberActions({ - org, - currentMember, - selectedCollabOrg, - }); - const { localMetadataError } = useLocalOrgMetadata(org); - - const orgSessions = useMemo( - () => - remoteSessions.filter( - (session) => session.orgId === selectedCollabOrg.orgId - ), - [remoteSessions, selectedCollabOrg.orgId] - ); - - const visibleSessions = useMemo( - () => - selectedMember - ? orgSessions.filter( - (session) => session.ownerMemberId === selectedMember.id - ) - : orgSessions, - [orgSessions, selectedMember] - ); - - const sessionItems = useMemo( - () => - visibleSessions.map((session) => - toSessionTableItem( - session, - t("collaboration.sessionStatusActive"), - t("collaboration.access.metadataOnlyBadge") - ) - ), - [visibleSessions, t] - ); - - const { handleSelectSession } = useSessionActions({ - orgSessions, - sessions, - currentMember, - t, - }); - - const activeMemberIds = useMemo( - () => - new Set( - orgSessions - .filter((session) => isToday(session.lastActivityAt)) - .map((session) => session.ownerMemberId) - ), - [orgSessions] - ); - - const orgChatMessages = useMemo( - () => - chatModel.chatMessages - .filter((message) => message.orgId === selectedCollabOrg.orgId) - .sort((left, right) => left.createdAt.localeCompare(right.createdAt)), - [chatModel.chatMessages, selectedCollabOrg.orgId] - ); - - const orgProjects = useMemo( - () => - projects.filter((project) => project.orgId === selectedCollabOrg.orgId), - [projects, selectedCollabOrg.orgId] - ); - - const orgWorkItems = useMemo( - () => - workItems.filter( - (workItem) => workItem.orgId === selectedCollabOrg.orgId - ), - [selectedCollabOrg.orgId, workItems] - ); - - const latestSnapshotRequest = useMemo( - () => - snapshotRequests - .filter((request) => request.orgId === selectedCollabOrg.orgId) - .sort((left, right) => - right.createdAt.localeCompare(left.createdAt) - )[0], - [selectedCollabOrg.orgId, snapshotRequests] - ); - - const handleSelectMember = useCallback( - (member: CollabMemberRecord) => { - setSelectedCollabOrg({ orgId: member.orgId, memberId: member.id }); - setActiveTab(COLLAB_ORG_TAB.MEMBERS); - }, - [setSelectedCollabOrg] - ); - - const handleBackToOrg = useCallback(() => { - setSelectedCollabOrg({ orgId: selectedCollabOrg.orgId }); - setActiveTab(COLLAB_ORG_TAB.SESSIONS); - }, [selectedCollabOrg.orgId, setSelectedCollabOrg]); - - const tabs = useMemo(() => { - const baseTabs = [ - { - key: COLLAB_ORG_TAB.WORK_ITEMS, - label: t("collaboration.tabs.workItems"), - }, - { key: COLLAB_ORG_TAB.PROJECTS, label: t("collaboration.tabs.projects") }, - { key: COLLAB_ORG_TAB.SESSIONS, label: t("collaboration.tabs.sessions") }, - { key: COLLAB_ORG_TAB.MEMBERS, label: t("collaboration.tabs.members") }, - { key: COLLAB_ORG_TAB.CHAT, label: t("collaboration.tabs.chat") }, - ]; - if (!currentMember) return baseTabs; - return [ - ...baseTabs, - { key: COLLAB_ORG_TAB.SETTINGS, label: t("collaboration.tabs.settings") }, - ]; - }, [currentMember, t]); - - return { - t, - org, - activeTab, - setActiveTab, - draftMessage: chatModel.draftMessage, - setDraftMessage: chatModel.setDraftMessage, - sending: chatModel.sending, - chatError: chatModel.chatError, - creatingInvite: memberActions.creatingInvite, - copyingInvite: memberActions.copyingInvite, - inviteError: memberActions.inviteError, - removingMemberId: memberActions.removingMemberId, - memberError: memberActions.memberError, - localMetadataError, - orgMembers, - selectedMember, - currentMember, - currentAccessSettings: accessSettingsModel.currentAccessSettings, - workspaceOptions: accessSettingsModel.workspaceOptions, - latestInvite: memberActions.latestInvite, - canCreateInvite: memberActions.canCreateInvite, - sessionItems, - activeMemberIds, - orgChatMessages, - orgProjects, - orgWorkItems, - latestSnapshotRequest, - tabs, - handleSendMessage: chatModel.handleSendMessage, - handleSelectAccessMode: accessSettingsModel.handleSelectAccessMode, - handleToggleWorkspace: accessSettingsModel.handleToggleWorkspace, - handleSelectMember, - handleSelectSession, - handleBackToOrg, - handleCreateInvite: memberActions.handleCreateInvite, - handleCopyInvite: memberActions.handleCopyInvite, - handleRemoveMember: memberActions.handleRemoveMember, - }; -} diff --git a/src/engines/ChatPanel/panels/CollabOrgPanelView/useLocalOrgMetadata.ts b/src/engines/ChatPanel/panels/CollabOrgPanelView/useLocalOrgMetadata.ts deleted file mode 100644 index 51984ae77..000000000 --- a/src/engines/ChatPanel/panels/CollabOrgPanelView/useLocalOrgMetadata.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { useSetAtom } from "jotai"; -import { useEffect, useState } from "react"; - -import { projectApi } from "@src/api/http/project"; -import { - collabProjectsAtom, - collabWorkItemsAtom, -} from "@src/store/collaboration/collabOrgsAtom"; -import { COLLAB_SYNC_BACKEND } from "@src/store/collaboration/types"; -import type { - CollabOrgRecord, - CollabProjectMetadataRecord, - CollabWorkItemMetadataRecord, -} from "@src/store/collaboration/types"; - -import { replaceOrgMetadata } from "./utils"; - -export function useLocalOrgMetadata(org: CollabOrgRecord | undefined) { - const setProjects = useSetAtom(collabProjectsAtom); - const setWorkItems = useSetAtom(collabWorkItemsAtom); - const [localMetadataError, setLocalMetadataError] = useState( - null - ); - - useEffect(() => { - if (!org || org.syncBackend === COLLAB_SYNC_BACKEND.SUPABASE) return; - let cancelled = false; - - const loadLocalOrgMetadata = async () => { - setLocalMetadataError(null); - const projectData = await projectApi.readProjects({ orgId: org.id }); - if (cancelled) return; - - const nextProjects: CollabProjectMetadataRecord[] = projectData.map( - (project) => ({ - id: project.meta.id, - orgId: org.id, - name: project.meta.name, - slug: project.slug, - status: project.meta.status, - priority: project.meta.priority, - health: project.meta.health, - lead: project.meta.lead, - description: project.description, - updatedAt: project.meta.updated_at, - }) - ); - - const workItemGroups = await Promise.all( - projectData.map(async (project) => { - const enrichedWorkItems = await projectApi.readWorkItemsEnriched( - project.slug, - { orgId: org.id } - ); - return enrichedWorkItems.map( - (workItem) => ({ - id: workItem.id, - orgId: org.id, - title: workItem.title, - status: workItem.status, - priority: workItem.priority, - projectId: workItem.project?.id ?? project.meta.id, - projectName: workItem.project?.name ?? project.meta.name, - assigneeName: workItem.assignee?.name, - updatedAt: workItem.updatedAt, - }) - ); - }) - ); - if (cancelled) return; - - setProjects((current) => - replaceOrgMetadata(current, org.id, nextProjects) - ); - setWorkItems((current) => - replaceOrgMetadata(current, org.id, workItemGroups.flat()) - ); - }; - - void loadLocalOrgMetadata().catch((error: unknown) => { - if (cancelled) return; - setLocalMetadataError( - error instanceof Error ? error.message : String(error) - ); - }); - - return () => { - cancelled = true; - }; - }, [org, setProjects, setWorkItems]); - - return { localMetadataError }; -} diff --git a/src/engines/ChatPanel/panels/CollabOrgPanelView/useMemberActions.ts b/src/engines/ChatPanel/panels/CollabOrgPanelView/useMemberActions.ts deleted file mode 100644 index 6dc4073ac..000000000 --- a/src/engines/ChatPanel/panels/CollabOrgPanelView/useMemberActions.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { useAtom, useSetAtom } from "jotai"; -import { useCallback, useMemo, useState } from "react"; - -import { supabaseSyncClient } from "@src/features/TeamCollaboration/sync/supabaseSyncClient"; -import { - collabInvitesAtom, - collabMembersAtom, -} from "@src/store/collaboration/collabOrgsAtom"; -import { COLLAB_ROLE } from "@src/store/collaboration/types"; -import type { - CollabMemberRecord, - CollabOrgRecord, -} from "@src/store/collaboration/types"; -import type { ChatPanelSelectedCollabOrg } from "@src/store/ui/chatPanelAtom"; -import { chatPanelSelectedCollabOrgAtom } from "@src/store/ui/chatPanelAtom"; -import { copyText } from "@src/util/data/clipboard"; - -import { DEFAULT_INVITE_USAGE_LIMIT } from "./constants"; -import { upsertInvite, upsertMember } from "./utils"; - -interface UseMemberActionsParams { - org: CollabOrgRecord | undefined; - currentMember: CollabMemberRecord | undefined; - selectedCollabOrg: ChatPanelSelectedCollabOrg; -} - -export function useMemberActions({ - org, - currentMember, - selectedCollabOrg, -}: UseMemberActionsParams) { - const [invites, setInvites] = useAtom(collabInvitesAtom); - const setMembers = useSetAtom(collabMembersAtom); - const setSelectedCollabOrg = useSetAtom(chatPanelSelectedCollabOrgAtom); - const [creatingInvite, setCreatingInvite] = useState(false); - const [copyingInvite, setCopyingInvite] = useState(false); - const [inviteError, setInviteError] = useState(null); - const [removingMemberId, setRemovingMemberId] = useState(null); - const [memberError, setMemberError] = useState(null); - - const latestInvite = useMemo( - () => - invites - .filter( - (invite) => - invite.orgId === selectedCollabOrg.orgId && !invite.revokedAt - ) - .sort((left, right) => - right.createdAt.localeCompare(left.createdAt) - )[0], - [invites, selectedCollabOrg.orgId] - ); - - const canCreateInvite = - Boolean(org?.supabaseUrl && org.supabaseAnonKey && org.orgSecret) && - currentMember?.role === COLLAB_ROLE.ADMIN; - - const handleCreateInvite = useCallback(async () => { - if ( - !org?.supabaseUrl || - !org.supabaseAnonKey || - !org.orgSecret || - creatingInvite - ) { - return; - } - setCreatingInvite(true); - setInviteError(null); - try { - const invite = await supabaseSyncClient.createInvite({ - supabaseUrl: org.supabaseUrl, - anonKey: org.supabaseAnonKey, - orgSecret: org.orgSecret, - orgId: org.id, - usageLimit: DEFAULT_INVITE_USAGE_LIMIT, - }); - setInvites((current) => upsertInvite(current, invite)); - await copyText(invite.inviteLink); - setCopyingInvite(true); - window.setTimeout(() => setCopyingInvite(false), 1500); - } catch (error) { - setInviteError(error instanceof Error ? error.message : String(error)); - } finally { - setCreatingInvite(false); - } - }, [creatingInvite, org, setInvites]); - - const handleCopyInvite = useCallback(async () => { - if (!latestInvite?.inviteLink || copyingInvite) return; - setInviteError(null); - try { - await copyText(latestInvite.inviteLink); - setCopyingInvite(true); - window.setTimeout(() => setCopyingInvite(false), 1500); - } catch (error) { - setInviteError(error instanceof Error ? error.message : String(error)); - } - }, [copyingInvite, latestInvite?.inviteLink]); - - const handleRemoveMember = useCallback( - async (member: CollabMemberRecord) => { - if ( - !org?.supabaseUrl || - !org.supabaseAnonKey || - !org.orgSecret || - removingMemberId - ) { - return; - } - setRemovingMemberId(member.id); - setMemberError(null); - try { - const removedMember = await supabaseSyncClient.removeMember({ - supabaseUrl: org.supabaseUrl, - anonKey: org.supabaseAnonKey, - orgSecret: org.orgSecret, - orgId: org.id, - memberId: member.id, - }); - setMembers((current) => upsertMember(current, removedMember)); - if (selectedCollabOrg.memberId === member.id) { - setSelectedCollabOrg({ orgId: selectedCollabOrg.orgId }); - } - } catch (error) { - setMemberError(error instanceof Error ? error.message : String(error)); - } finally { - setRemovingMemberId(null); - } - }, - [ - org, - removingMemberId, - selectedCollabOrg.memberId, - selectedCollabOrg.orgId, - setMembers, - setSelectedCollabOrg, - ] - ); - - return { - latestInvite, - canCreateInvite, - creatingInvite, - copyingInvite, - inviteError, - removingMemberId, - memberError, - handleCreateInvite, - handleCopyInvite, - handleRemoveMember, - }; -} diff --git a/src/engines/ChatPanel/panels/CollabOrgPanelView/useSessionActions.ts b/src/engines/ChatPanel/panels/CollabOrgPanelView/useSessionActions.ts deleted file mode 100644 index ff150711d..000000000 --- a/src/engines/ChatPanel/panels/CollabOrgPanelView/useSessionActions.ts +++ /dev/null @@ -1,96 +0,0 @@ -import type { TFunction } from "i18next"; -import { useSetAtom } from "jotai"; -import { useCallback } from "react"; - -import { useSessionView } from "@src/hooks/ui/tabs/useSessionView"; -import type { SessionTableItem } from "@src/modules/shared/layouts/blocks"; -import { collabSessionSnapshotRequestsAtom } from "@src/store/collaboration/collabOrgsAtom"; -import { COLLAB_SESSION_ACCESS_MODE } from "@src/store/collaboration/types"; -import type { - CollabMemberRecord, - RemoteTeammateSessionMetadata, -} from "@src/store/collaboration/types"; -import type { Session } from "@src/store/session"; - -import { COLLAB_SNAPSHOT_REQUEST_STATUS } from "./constants"; - -interface UseSessionActionsParams { - orgSessions: RemoteTeammateSessionMetadata[]; - sessions: Session[]; - currentMember: CollabMemberRecord | undefined; - t: TFunction<"navigation">; -} - -export function useSessionActions({ - orgSessions, - sessions, - currentMember, - t, -}: UseSessionActionsParams) { - const { openSession } = useSessionView(); - const setSnapshotRequests = useSetAtom(collabSessionSnapshotRequestsAtom); - - const handleSelectSession = useCallback( - (item: SessionTableItem) => { - const remoteSession = orgSessions.find( - (session) => session.id === item.id - ); - if (!remoteSession) return; - - const localSession = sessions.find( - (session) => - session.session_id === remoteSession.sourceSessionId || - session.session_id === remoteSession.id - ); - - if (localSession) { - openSession( - localSession.session_id, - localSession.name || localSession.user_input || remoteSession.title, - localSession.repoPath ?? remoteSession.repoPath - ); - return; - } - - if (remoteSession.accessMode !== COLLAB_SESSION_ACCESS_MODE.FULL_REPLAY) { - setSnapshotRequests((current) => [ - { - requestId: crypto.randomUUID(), - orgId: remoteSession.orgId, - requesterMemberId: currentMember?.id ?? "local-member", - ownerMemberId: remoteSession.ownerMemberId, - sourceSessionId: remoteSession.sourceSessionId, - createdAt: new Date().toISOString(), - status: COLLAB_SNAPSHOT_REQUEST_STATUS.DENIED, - error: t("collaboration.access.metadataOnlyDenied"), - }, - ...current, - ]); - return; - } - - setSnapshotRequests((current) => [ - { - requestId: crypto.randomUUID(), - orgId: remoteSession.orgId, - requesterMemberId: currentMember?.id ?? "local-member", - ownerMemberId: remoteSession.ownerMemberId, - sourceSessionId: remoteSession.sourceSessionId, - createdAt: new Date().toISOString(), - status: COLLAB_SNAPSHOT_REQUEST_STATUS.PENDING, - }, - ...current, - ]); - }, - [ - currentMember?.id, - openSession, - orgSessions, - sessions, - setSnapshotRequests, - t, - ] - ); - - return { handleSelectSession }; -} diff --git a/src/engines/ChatPanel/panels/CollabOrgPanelView/utils.ts b/src/engines/ChatPanel/panels/CollabOrgPanelView/utils.ts deleted file mode 100644 index cca8be81c..000000000 --- a/src/engines/ChatPanel/panels/CollabOrgPanelView/utils.ts +++ /dev/null @@ -1,169 +0,0 @@ -import type { SessionTableItem } from "@src/modules/shared/layouts/blocks"; -import { - COLLAB_CONNECTION_STATUS, - COLLAB_SESSION_ACCESS_MODE, -} from "@src/store/collaboration/types"; -import type { - CollabChatMessageRecord, - CollabInviteRecord, - CollabMemberRecord, - CollabSessionAccessSettings, - RemoteTeammateSessionMetadata, -} from "@src/store/collaboration/types"; -import { formatSmartDateTime } from "@src/util/data/formatters/date"; - -const SESSION_STATUS_COLOR = { - [COLLAB_CONNECTION_STATUS.CONNECTED]: "var(--color-success-6)", - [COLLAB_CONNECTION_STATUS.CONNECTING]: "var(--color-warning-6)", - [COLLAB_CONNECTION_STATUS.DISCONNECTED]: "var(--color-text-4)", - [COLLAB_CONNECTION_STATUS.ERROR]: "var(--color-danger-6)", -} as const; - -export function createLocalChatMessageId(orgId: string): string { - return `${orgId}:${Date.now()}:${Math.random().toString(36).slice(2, 8)}`; -} - -export function formatSessionDate( - value: string | undefined -): string | undefined { - if (!value) return undefined; - return formatSmartDateTime(value); -} - -export function getInviteRemainingUses(invite: CollabInviteRecord): number { - return Math.max(0, invite.usageLimit - invite.usageCount); -} - -export function isToday(value: string | undefined): boolean { - if (!value) return false; - const date = new Date(value); - if (Number.isNaN(date.getTime())) return false; - const now = new Date(); - return ( - date.getFullYear() === now.getFullYear() && - date.getMonth() === now.getMonth() && - date.getDate() === now.getDate() - ); -} - -export function normalizeWorkspacePath( - path: string | undefined -): string | null { - const trimmed = path?.trim(); - if (!trimmed) return null; - return trimmed.replace(/\/+$/, ""); -} - -export function toSessionTableItem( - session: RemoteTeammateSessionMetadata, - fallbackStatusLabel: string, - metadataOnlyLabel: string -): SessionTableItem { - return { - id: session.id, - title: session.title, - description: session.ownerDisplayName, - statusLabel: - session.accessMode === COLLAB_SESSION_ACCESS_MODE.METADATA_ONLY - ? metadataOnlyLabel - : (session.status ?? fallbackStatusLabel), - statusColor: SESSION_STATUS_COLOR[COLLAB_CONNECTION_STATUS.CONNECTED], - agentLabel: session.ownerDisplayName, - workspaceLabel: session.repoPath, - workspaceTitle: session.repoPath, - modelLabel: session.branch, - startedLabel: formatSessionDate(session.lastActivityAt), - lastUpdatedLabel: formatSessionDate(session.lastActivityAt), - }; -} - -export function upsertChatMessage( - messages: CollabChatMessageRecord[], - incoming: CollabChatMessageRecord -): CollabChatMessageRecord[] { - const existingIndex = messages.findIndex( - (message) => message.id === incoming.id - ); - if (existingIndex < 0) return [...messages, incoming]; - const next = [...messages]; - next[existingIndex] = incoming; - return next; -} - -export function upsertInvite( - invites: CollabInviteRecord[], - incoming: CollabInviteRecord -): CollabInviteRecord[] { - const existingIndex = invites.findIndex( - (invite) => invite.id === incoming.id - ); - if (existingIndex < 0) return [incoming, ...invites]; - const next = [...invites]; - next[existingIndex] = incoming; - return next; -} - -export function replaceOrgMetadata>( - records: TRecord[], - orgId: string, - incoming: TRecord[] -): TRecord[] { - return [...incoming, ...records.filter((record) => record.orgId !== orgId)]; -} - -export function upsertMember( - members: CollabMemberRecord[], - incoming: CollabMemberRecord -): CollabMemberRecord[] { - const existingIndex = members.findIndex( - (member) => member.id === incoming.id - ); - if (existingIndex < 0) return [incoming, ...members]; - const next = [...members]; - next[existingIndex] = { ...members[existingIndex], ...incoming }; - return next; -} - -export function getStringField( - record: Record, - fieldNames: string[], - fallback = "—" -): string { - for (const fieldName of fieldNames) { - const value = record[fieldName]; - if (typeof value === "string" && value.trim()) return value; - } - return fallback; -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -export function getRecordField( - record: Record, - fieldName: string -): Record | null { - const value = record[fieldName]; - return isRecord(value) ? value : null; -} - -export function getMetadataId(record: Record): string | null { - const id = record.id; - return typeof id === "string" && id.trim() ? id : null; -} - -export function createDefaultAccessSettings( - orgId: string, - memberId: string, - workspaceScope: CollabSessionAccessSettings["workspaceScope"] -): CollabSessionAccessSettings { - return { - orgId, - memberId, - accessMode: COLLAB_SESSION_ACCESS_MODE.OFF, - workspaceScope, - workspacePaths: [], - updatedAt: new Date().toISOString(), - }; -} diff --git a/src/engines/ChatPanel/panels/ProjectOrgPanelView.tsx b/src/engines/ChatPanel/panels/ProjectOrgPanelView.tsx index d3633afb1..57b3fe475 100644 --- a/src/engines/ChatPanel/panels/ProjectOrgPanelView.tsx +++ b/src/engines/ChatPanel/panels/ProjectOrgPanelView.tsx @@ -131,7 +131,6 @@ export const ProjectOrgPanelView: React.FC = ({ orgScope={selectedProjectOrg.orgScope} orgView={orgView} breadcrumbSegments={[{ label: selectedProjectOrg.orgName }]} - workStationTabId={`chat-panel-project-org:${selectedProjectOrg.orgId}`} renderSurfaceControlsInline onOrgViewChange={setOrgView} onSelectProject={handleSelectProject} diff --git a/src/engines/ChatPanel/panels/ProjectPanelView.tsx b/src/engines/ChatPanel/panels/ProjectPanelView.tsx index 3b5f54fd4..bca3edcf1 100644 --- a/src/engines/ChatPanel/panels/ProjectPanelView.tsx +++ b/src/engines/ChatPanel/panels/ProjectPanelView.tsx @@ -8,19 +8,45 @@ import React, { } from "react"; import { useTranslation } from "react-i18next"; -import { enrichedWorkItemToUI, projectApi } from "@src/api/http/project"; +import { + type MemberEntry, + type WorkItemFrontmatter, + enrichedWorkItemToUI, + projectApi, +} from "@src/api/http/project"; import TabPill from "@src/components/TabPill"; +import type { TabPillItem } from "@src/components/TabPill"; import { ChatPanelHeaderBreadcrumb, usePublishChatPanelHeader, } from "@src/engines/ChatPanel/header"; +import KanbanBoard from "@src/features/KanbanBoard"; +import type { KanbanTask, TaskStatus } from "@src/features/KanbanBoard"; +import { allocateCloudAwareWorkItemId } from "@src/features/Org2Cloud/cloudShortId"; import { createLogger } from "@src/hooks/logger"; -import { useProjectDataChanged } from "@src/hooks/project"; +import { + useCurrentUserMemberIds, + useProjectDataChanged, +} from "@src/hooks/project"; import WorkItemContentStack from "@src/modules/ProjectManager/WorkItems/components/WorkItemContentStack"; import { MultiSelectBar } from "@src/modules/ProjectManager/WorkItems/components/WorkItemsFooterBars"; import WorkItemsListContent from "@src/modules/ProjectManager/WorkItems/components/WorkItemsListContent"; +import WorkItemsStatusFilterSelect from "@src/modules/ProjectManager/WorkItems/components/WorkItemsStatusFilterSelect"; import { useMultiSelect } from "@src/modules/ProjectManager/WorkItems/hooks/useMultiSelect"; -import { groupWorkItemsForStatusFilter } from "@src/modules/ProjectManager/WorkItems/workItemsViewModel"; +import { + type StatusFilterType, + WORK_ITEMS_DEFAULT_STATUS, +} from "@src/modules/ProjectManager/WorkItems/types"; +import { + WORK_ITEMS_KANBAN_GROUP, + type WorkItemsKanbanGroup, + countWorkItemsByStatus, + filterWorkItemsByStatus, + getStatusFilterKeysForWorkItems, + getWorkItemsKanbanColumns, + groupWorkItemsForStatusFilter, + workItemsToKanbanTasks, +} from "@src/modules/ProjectManager/WorkItems/workItemsViewModel"; import { PROJECT_PROPERTY_CONCISE_FIELDS, ProjectContentEditor, @@ -44,13 +70,13 @@ import type { WorkItem } from "@src/types/core/workItem"; const logger = createLogger("ProjectPanelView"); -type ProjectPanelTab = "overview" | "workItems"; +type ProjectPanelTab = "overview" | "list" | "kanban"; interface ProjectPanelViewProps { selectedProject: ChatPanelSelectedProject; } -const PROJECT_PANEL_TABS: ProjectPanelTab[] = ["overview", "workItems"]; +const PROJECT_PANEL_TABS: ProjectPanelTab[] = ["overview", "list", "kanban"]; function getProjectOverviewDescription( project: ChatPanelSelectedProject["project"] @@ -67,8 +93,11 @@ export const ProjectPanelView: React.FC = ({ const sidebarProjectDescription = getProjectOverviewDescription( selectedProject.project ); - const [activePanelTab, setActivePanelTab] = - useState("overview"); + const [activePanelTab, setActivePanelTab] = useState("list"); + const [statusFilter, setStatusFilter] = useState("all"); + const [kanbanGroupBy, setKanbanGroupBy] = useState( + WORK_ITEMS_KANBAN_GROUP.STATUS + ); const [projectDescription, setProjectDescription] = useState( sidebarProjectDescription ); @@ -235,6 +264,68 @@ export const ProjectPanelView: React.FC = ({ [getWorkItemShortId, loadProjectWorkItems, projectSlug] ); + const statusCounts = useMemo( + () => countWorkItemsByStatus(workItems), + [workItems] + ); + + const statusFilterKeys = useMemo( + () => getStatusFilterKeysForWorkItems(workItems), + [workItems] + ); + useEffect(() => { + if (!statusFilterKeys.includes(statusFilter)) { + setStatusFilter("all"); + } + }, [statusFilter, statusFilterKeys]); + + const filteredWorkItems = useMemo( + () => filterWorkItemsByStatus(workItems, statusFilter), + [statusFilter, workItems] + ); + + const groupedWorkItems = useMemo( + () => groupWorkItemsForStatusFilter(filteredWorkItems, statusFilter), + [filteredWorkItems, statusFilter] + ); + + const workItemPeople = useMemo(() => { + const people = new Map(); + for (const workItem of workItems) { + for (const person of [workItem.assignee, workItem.createdBy]) { + if (!person) continue; + people.set(person.id, { + id: person.id, + name: person.name, + avatar: person.avatar, + active: true, + }); + } + } + return [...people.values()]; + }, [workItems]); + const { memberIds: currentUserMemberIds } = + useCurrentUserMemberIds(workItemPeople); + const pinnedKanbanColumnIds = useMemo( + () => [...currentUserMemberIds].map((memberId) => `person:${memberId}`), + [currentUserMemberIds] + ); + + const kanbanTasks = useMemo( + () => workItemsToKanbanTasks(filteredWorkItems, kanbanGroupBy), + [filteredWorkItems, kanbanGroupBy] + ); + const kanbanColumns = useMemo( + () => + getWorkItemsKanbanColumns( + filteredWorkItems, + kanbanGroupBy, + t("projects:workItems.properties.noAssignee"), + pinnedKanbanColumnIds + ), + [filteredWorkItems, kanbanGroupBy, pinnedKanbanColumnIds, t] + ); + const { selectedIds, bulkDeleting, @@ -243,7 +334,7 @@ export const ProjectPanelView: React.FC = ({ handleUnselectAll, handleBulkDelete, } = useMultiSelect({ - filteredWorkItems: workItems, + filteredWorkItems, onDelete: handleDeleteWorkItem, projectSlug, getShortId: getWorkItemShortId, @@ -323,8 +414,27 @@ export const ProjectPanelView: React.FC = ({ label: tab === "overview" ? t("projects:orgs.management.overview") - : t("projects:workItems.label"), + : tab === "list" + ? t("projects:workItems.tabs.list") + : t("projects:workItems.tabs.kanban"), })); + const kanbanGroupTabs = useMemo( + () => [ + { + key: WORK_ITEMS_KANBAN_GROUP.STATUS, + label: t("projects:projects.groupBy.status"), + }, + { + key: WORK_ITEMS_KANBAN_GROUP.ASSIGNED_TO, + label: t("projects:projects.groupBy.assignedTo"), + }, + { + key: WORK_ITEMS_KANBAN_GROUP.CREATED_BY, + label: t("projects:projects.groupBy.createdBy"), + }, + ], + [t] + ); const handleSelectWorkItem = useCallback( (workItemId: string) => { @@ -353,6 +463,74 @@ export const ProjectPanelView: React.FC = ({ ] ); + const handleSelectWorkItemFromKanban = useCallback( + (task: KanbanTask) => { + handleSelectWorkItem(task.id); + }, + [handleSelectWorkItem] + ); + + const handleUpdateWorkItem = useCallback( + async (workItemId: string, updates: Partial) => { + if (!projectSlug) return; + const shortId = getWorkItemShortId(workItemId); + if (!shortId) return; + + const payload = {} as Parameters< + typeof projectApi.updateWorkItemPartial + >[2]; + if (updates.name !== undefined) payload.title = updates.name; + if (updates.spec !== undefined) payload.body = updates.spec; + if (updates.workItemStatus !== undefined) { + payload.status = updates.workItemStatus; + } + if (updates.priority !== undefined) payload.priority = updates.priority; + if (Object.keys(payload).length === 0) return; + + const updated = await projectApi.updateWorkItemPartial( + projectSlug, + shortId, + payload + ); + const updatedItem = enrichedWorkItemToUI(updated); + setWorkItems((currentItems) => + currentItems.map((item) => + item.session_id === workItemId ? updatedItem : item + ) + ); + }, + [getWorkItemShortId, projectSlug] + ); + + const handleAddKanbanTask = useCallback( + async (status: TaskStatus) => { + if (!projectSlug) return; + // Collab-synced orgs allocate on the server (design §16.5) — a local + // counter here could mint the same PREFIX-n as a teammate and merge + // two distinct work items on push. + const shortId = await allocateCloudAwareWorkItemId(projectSlug); + const now = new Date().toISOString(); + const frontmatter: WorkItemFrontmatter = { + id: shortId, + short_id: shortId, + title: t("projects:workItems.newWorkItemName", { + defaultValue: "New Work Item", + }), + project: selectedProject.project.id, + status: status || WORK_ITEMS_DEFAULT_STATUS, + priority: "none", + labels: [], + created_at: now, + updated_at: now, + starred: false, + todos: [], + }; + await projectApi.writeWorkItem(projectSlug, shortId, frontmatter, ""); + await loadProjectWorkItems(); + }, + [loadProjectWorkItems, projectSlug, selectedProject.project.id, t] + ); + const handleDescriptionChange = useCallback((markdown: string) => { setProjectDescription(markdown); }, []); @@ -384,11 +562,6 @@ export const ProjectPanelView: React.FC = ({ ); - const groupedWorkItems = useMemo( - () => groupWorkItemsForStatusFilter(workItems, "all"), - [workItems] - ); - const workItemsContent = workItemsLoading ? ( = ({ }} /> ) : ( - +
+ {activePanelTab === "kanban" ? ( +
+ { + if (kanbanGroupBy !== WORK_ITEMS_KANBAN_GROUP.STATUS) return; + void handleUpdateWorkItem(taskId, { + workItemStatus: newStatus as WorkItem["workItemStatus"], + }); + }} + onTaskClick={handleSelectWorkItemFromKanban} + onAddTask={(status: TaskStatus) => { + void handleAddKanbanTask(status); + }} + showAddButton={kanbanGroupBy === WORK_ITEMS_KANBAN_GROUP.STATUS} + className="kanban-board--linear" + /> +
+ ) : ( + + )} +
); const descriptionContent = ( @@ -434,17 +639,40 @@ export const ProjectPanelView: React.FC = ({ className="flex min-h-0 flex-1 flex-col" data-testid="chat-panel-project-section" > -
+
setActivePanelTab(key as ProjectPanelTab)} variant="simple" fillWidth={false} - size="large" + size="chatPanel" /> + {activePanelTab !== "overview" ? ( +
+ {activePanelTab === "kanban" ? ( + + setKanbanGroupBy(key as WorkItemsKanbanGroup) + } + variant="pill" + color="fill" + fillWidth={false} + size="small" + /> + ) : null} + +
+ ) : null}
-
+
{activePanelTab === "overview" ? overviewContent : workItemsContent}
@@ -460,9 +688,9 @@ export const ProjectPanelView: React.FC = ({ propertiesContent={inlineProperties} descriptionContent={descriptionContent} descriptionFlexible - scrollable + descriptionClassName="min-h-0 flex flex-1 flex-col px-4 py-4" /> - {activePanelTab === "workItems" ? ( + {activePanelTab !== "overview" ? ( = memo( ); diff --git a/src/engines/ChatPanel/panels/WorkItemPanelView.tsx b/src/engines/ChatPanel/panels/WorkItemPanelView.tsx index 7e0c6704f..0f589204d 100644 --- a/src/engines/ChatPanel/panels/WorkItemPanelView.tsx +++ b/src/engines/ChatPanel/panels/WorkItemPanelView.tsx @@ -1,6 +1,6 @@ import { emit } from "@tauri-apps/api/event"; -import { useSetAtom } from "jotai"; -import { ExternalLink, X } from "lucide-react"; +import { useAtomValue, useSetAtom } from "jotai"; +import { ExternalLink, Trash2, X } from "lucide-react"; import React, { useCallback, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -9,18 +9,23 @@ import { type WorkItemPartialUpdate, enrichedWorkItemToUI, projectApi, + standaloneWorkItemDataToEnriched, } from "@src/api/http/project"; import Button from "@src/components/Button"; +import { HEADER_ICON_SIZE } from "@src/config/workstation/tokens"; import { ChatPanelHeaderBreadcrumb, usePublishChatPanelHeader, } from "@src/engines/ChatPanel/header"; import { createLogger } from "@src/hooks/logger"; +import { useProjectDataChanged } from "@src/hooks/project"; import { WorkItemContent, WorkItemProperties, } from "@src/modules/ProjectManager/WorkItems/components"; import { WORK_ITEM_PROPERTY_INLINE_FIELDS } from "@src/modules/ProjectManager/WorkItems/components/WorkItemProperties"; +import { useWorkItemOrchestrator } from "@src/modules/ProjectManager/WorkItems/hooks"; +import { WorkstationToolbarTooltip } from "@src/modules/WorkStation/shared"; import { activeSessionIdAtom } from "@src/store/session"; import { CHAT_PANEL_SURFACE_KIND, @@ -28,15 +33,19 @@ import { chatPanelNavigateAtom, chatPanelSelectedWorkItemAtom, } from "@src/store/ui/chatPanelAtom"; +import { activeWorkspaceRootPathAtom } from "@src/store/workspace"; import { STORY_ORG_SCOPE, STORY_PERSONAL_ORG_FILTER_ID, } from "@src/store/workstation"; import type { WorkItem } from "@src/types/core/workItem"; +import { confirmDestructiveAction } from "@src/util/dialogs/confirmDestructiveAction"; import ChatView from "../ChatView"; const logger = createLogger("WorkItemPanelView"); +const saveNoPendingWorkItemChanges = async (): Promise => undefined; + interface WorkItemPanelViewProps { selectedWorkItem: ChatPanelSelectedWorkItem; onUpdateWorkItem?: (updates: Partial) => void; @@ -165,6 +174,7 @@ export const WorkItemPanelView: React.FC = ({ const navigateChatPanel = useSetAtom(chatPanelNavigateAtom); const setSelectedWorkItem = useSetAtom(chatPanelSelectedWorkItemAtom); const setActiveSessionId = useSetAtom(activeSessionIdAtom); + const activeWorkspaceRootPath = useAtomValue(activeWorkspaceRootPathAtom); const [floatingSessionId, setFloatingSessionId] = useState( null ); @@ -197,10 +207,17 @@ export const WorkItemPanelView: React.FC = ({ selectedWorkItem.workItem, updates ); + // Keep the item under its owning org — the Rust upsert + // overwrites org_id on conflict, so an orgless write would + // re-home a collab-org item to personal-org and detach it + // from sync. await projectApi.writeStandaloneWorkItem( selectedWorkItem.shortId, toStandaloneFrontmatter(updatedWorkItem, selectedWorkItem.shortId), - updatedWorkItem.spec ?? "" + updatedWorkItem.spec ?? "", + selectedWorkItem.orgId + ? { orgId: selectedWorkItem.orgId } + : undefined ); setSelectedWorkItem({ ...selectedWorkItem, @@ -215,6 +232,104 @@ export const WorkItemPanelView: React.FC = ({ [onUpdateWorkItem, selectedWorkItem, setSelectedWorkItem] ); + const refreshSelectedWorkItem = useCallback(async () => { + try { + if (selectedWorkItem.projectSlug) { + const projects = await projectApi.readProjects(); + const projectStillExists = projects.some( + (project) => project.slug === selectedWorkItem.projectSlug + ); + if (!projectStillExists) { + // Reading the deleted project's items throws before it can return an + // empty list, so detect the parent tombstone explicitly. The local + // project store is authoritative even when cloud transport is down. + setSelectedWorkItem(null); + return; + } + const items = await projectApi.readWorkItemsEnriched( + selectedWorkItem.projectSlug, + selectedWorkItem.orgId ? { orgId: selectedWorkItem.orgId } : undefined + ); + const fresh = items.find( + (item) => item.shortId === selectedWorkItem.shortId + ); + if (!fresh) { + // A collaborator may delete the item itself or its parent project + // while this detail is open. Do not leave an editable ghost surface. + setSelectedWorkItem(null); + return; + } + setSelectedWorkItem((current) => + current?.projectSlug === selectedWorkItem.projectSlug && + current.shortId === selectedWorkItem.shortId && + current.orgId === selectedWorkItem.orgId + ? { ...current, workItem: enrichedWorkItemToUI(fresh) } + : current + ); + return; + } + if (!selectedWorkItem.shortId) return; + const data = await projectApi.readStandaloneWorkItem( + selectedWorkItem.shortId, + selectedWorkItem.orgId ? { orgId: selectedWorkItem.orgId } : undefined + ); + setSelectedWorkItem((current) => + current?.shortId === selectedWorkItem.shortId && + current.orgId === selectedWorkItem.orgId + ? { + ...current, + workItem: enrichedWorkItemToUI( + standaloneWorkItemDataToEnriched(data) + ), + } + : current + ); + } catch (error) { + logger.warn("Failed to refresh chat panel work item", error); + } + }, [selectedWorkItem, setSelectedWorkItem]); + + useProjectDataChanged( + useCallback(() => { + void refreshSelectedWorkItem(); + }, [refreshSelectedWorkItem]), + // A detail surface can mount from a cached navigation payload after the + // mutation signal already fired. Refreshing on mount closes that race; + // subsequent signals keep the open panel live. + { fireOnMount: true } + ); + + // The chat-panel detail is a second presentation of the same Work Item, + // not a read-only workflow mock. Reuse the canonical orchestrator hook so + // agent actions, cloud execution locks, and lock-holder labels behave the + // same here as they do in the full Project Manager detail. + const repoPath = + selectedWorkItem.sourceProject?.project.linkedRepos?.[0]?.id ?? + activeWorkspaceRootPath ?? + null; + const { + isStartingAgent, + activeAgentSessionId, + activeAgentRole, + handleStartAgent, + handleRetry, + handleCancelAgent, + handleAcceptAsIs, + handleCreateFollowUp, + isLockedByOther, + lockHolderName, + } = useWorkItemOrchestrator({ + workItem: selectedWorkItem.workItem, + displayWorkItem: selectedWorkItem.workItem, + repoPath, + projectSlug: selectedWorkItem.projectSlug, + shortId: selectedWorkItem.shortId, + onRefreshWorkItem: refreshSelectedWorkItem, + onUpdateWorkItem: handleUpdateWorkItem, + hasPendingChanges: false, + handleSave: saveNoPendingWorkItemChanges, + }); + const handleOpenSession = useCallback( (sessionId: string) => { setFloatingSessionId(sessionId); @@ -231,9 +346,6 @@ export const WorkItemPanelView: React.FC = ({ () => selectedWorkItem.workItem.linkedSessions ?? [], [selectedWorkItem.workItem.linkedSessions] ); - const activeLinkedSession = linkedSessions.find( - (session) => session.status === "running" - ); const floatingSession = useMemo( () => floatingSessionId @@ -319,8 +431,57 @@ export const WorkItemPanelView: React.FC = ({ ] ); + const handleDeleteWorkItem = useCallback(async () => { + if (!selectedWorkItem.projectSlug) return; + + const confirmed = await confirmDestructiveAction({ + title: t("common:actions.confirmDeleteTitle", { + name: selectedWorkItem.workItem.name, + }), + message: t("common:actions.confirmDeleteMessage"), + okLabel: t("common:actions.delete"), + cancelLabel: t("common:actions.cancel"), + }); + if (!confirmed) return; + + try { + await projectApi.deleteWorkItem( + selectedWorkItem.projectSlug, + selectedWorkItem.shortId + ); + setSelectedWorkItem(null); + await emit("orgii-data-changed"); + } catch (error) { + logger.error("Failed to delete chat panel work item", error); + } + }, [selectedWorkItem, setSelectedWorkItem, t]); + + const headerDeleteAction = useMemo( + () => + selectedWorkItem.projectSlug ? ( + +
); diff --git a/src/engines/ChatPanel/rendering/adapters/OrgTaskAdapter.tsx b/src/engines/ChatPanel/rendering/adapters/OrgTaskAdapter.tsx index 0814ef754..d2da415d1 100644 --- a/src/engines/ChatPanel/rendering/adapters/OrgTaskAdapter.tsx +++ b/src/engines/ChatPanel/rendering/adapters/OrgTaskAdapter.tsx @@ -2,6 +2,7 @@ import React from "react"; import { useAgentTurnContext } from "@src/engines/ChatPanel/ChatHistory/AgentTurnContext"; import type { RustOrgTaskItem } from "@src/engines/SessionCore/core/types"; +import { resolveOrgTaskOperationOutcome } from "@src/engines/SessionCore/rendering/orgTaskOutcome"; import { statusToLifecycle, useLifecycleLabels, @@ -100,13 +101,33 @@ export const OrgTaskAdapter: React.FC = (props) => { const extracted = props.rustExtracted; const isSimulator = props.variant === "simulator"; + const operationOutcome = resolveOrgTaskOperationOutcome( + extracted, + props.result, + props.status + ); - if (extracted.action === "list") + if (extracted.action === "list" && operationOutcome === "succeeded") return renderListCard(props, groupSenderName); const task = extracted.task ?? extracted.tasks?.[0]; - if (!task) return null; + if (!task) { + return ( + + ); + } - if (extracted.action === "get") { + if (extracted.action === "get" && operationOutcome === "succeeded") { return renderListCard( { ...props, @@ -139,6 +160,8 @@ export const OrgTaskAdapter: React.FC = (props) => { ownerChanged={extracted.ownerChanged} statusChanged={extracted.statusChanged} taskAssignedDispatched={extracted.taskAssignedDispatched} + operationOutcome={operationOutcome} + operationMessage={extracted.guidance ?? extracted.errorMessage} isLoading={ props.status === "running" && props.showActiveEventPainting === true } @@ -146,6 +169,7 @@ export const OrgTaskAdapter: React.FC = (props) => { timestamp={props.timestamp} hideHeader={isSimulator} groupSenderName={groupSenderName} + toolUsage={props.toolUsage} />
); diff --git a/src/engines/ChatPanel/rendering/adapters/PlanDocAdapter.tsx b/src/engines/ChatPanel/rendering/adapters/PlanDocAdapter.tsx index c8288b00d..1a8e26010 100644 --- a/src/engines/ChatPanel/rendering/adapters/PlanDocAdapter.tsx +++ b/src/engines/ChatPanel/rendering/adapters/PlanDocAdapter.tsx @@ -99,6 +99,7 @@ export const PlanDocAdapter: React.FC = (props) => { approvalStatus={surfaceState?.status ?? approvalStatus} ownsPendingPlan={surfaceState?.ownsActions ?? false} surfaceState={surfaceState} + toolUsage={props.toolUsage} />
); diff --git a/src/engines/ChatPanel/rendering/adapters/SearchAdapter.tsx b/src/engines/ChatPanel/rendering/adapters/SearchAdapter.tsx index 3ec8ddf18..bce4d4a6b 100644 --- a/src/engines/ChatPanel/rendering/adapters/SearchAdapter.tsx +++ b/src/engines/ChatPanel/rendering/adapters/SearchAdapter.tsx @@ -39,6 +39,7 @@ export const SearchAdapter: React.FC = (props) => { eventId={props.eventId} action={searchAction} title={title} + toolUsage={props.toolUsage} />
); diff --git a/src/engines/ChatPanel/rendering/adapters/SetupRepoAdapter.tsx b/src/engines/ChatPanel/rendering/adapters/SetupRepoAdapter.tsx index b0bd20c7a..2339bf5be 100644 --- a/src/engines/ChatPanel/rendering/adapters/SetupRepoAdapter.tsx +++ b/src/engines/ChatPanel/rendering/adapters/SetupRepoAdapter.tsx @@ -60,6 +60,7 @@ export const SetupRepoAdapter: React.FC = (props) => { props.status === "running" && props.showActiveEventPainting === true } isFailed={props.status === "failed"} + toolUsage={props.toolUsage} />
); diff --git a/src/engines/ChatPanel/rendering/adapters/ShellAdapter.tsx b/src/engines/ChatPanel/rendering/adapters/ShellAdapter.tsx index 29e7cccde..59b67ba25 100644 --- a/src/engines/ChatPanel/rendering/adapters/ShellAdapter.tsx +++ b/src/engines/ChatPanel/rendering/adapters/ShellAdapter.tsx @@ -6,6 +6,7 @@ * `await_output` is a separate chat_block (`TitleOnly`) and never reaches * this adapter; it's rendered by `TitleOnlyAdapter` directly. */ +import { useAtomValue } from "jotai"; import React from "react"; import { @@ -13,6 +14,7 @@ import { useLifecycleLabels, } from "@src/engines/SessionCore/rendering/registry"; import type { UniversalEventProps } from "@src/engines/SessionCore/rendering/types/universalProps"; +import { tuiModeAtom } from "@src/store/session/tuiModeAtom"; import { ShellBlock } from "../../blocks/ShellBlock"; @@ -23,6 +25,7 @@ export const ShellAdapter: React.FC = (props) => { const state = statusToLifecycle(props.status); const toolName = props.eventType || props.functionName; + const tuiMode = useAtomValue(tuiModeAtom(props.sessionId ?? "")); return (
@@ -31,6 +34,7 @@ export const ShellAdapter: React.FC = (props) => { title={runLabels[state]} killTitle={killLabels[state]} failedLabel={runLabels.failed} + tuiRendering={tuiMode} />
); diff --git a/src/engines/ChatPanel/rendering/adapters/SubagentAdapter.tsx b/src/engines/ChatPanel/rendering/adapters/SubagentAdapter.tsx index 6353020f3..bc063cb56 100644 --- a/src/engines/ChatPanel/rendering/adapters/SubagentAdapter.tsx +++ b/src/engines/ChatPanel/rendering/adapters/SubagentAdapter.tsx @@ -17,43 +17,23 @@ import { useAtomValue, useSetAtom } from "jotai"; import React, { useCallback, useMemo } from "react"; import { navigateToEventAtom } from "@src/engines/SessionCore/core/atoms/actions"; -import type { SessionEvent } from "@src/engines/SessionCore/core/types"; import { chatEventsForSessionAtomFamily } from "@src/engines/SessionCore/derived/sessionScopedChatEvents"; import type { UniversalEventProps } from "@src/engines/SessionCore/rendering/types/universalProps"; +import { chatPanelMaximizedAtom } from "@src/store/ui/chatPanelAtom"; import { focusedSubagentCellAtom, + stationModeAtom, subagentPanelRevealRequestAtom, } from "@src/store/ui/simulatorAtom"; import SubagentBlock from "../../blocks/SubagentBlock"; +import { + extractSubagentPromptFromChildEvents, + firstSubagentAssignmentPrompt, +} from "./subagentPrompt"; const EMPTY_SUBAGENT_SESSION_ID = "__no-subagent-session__"; -function nonEmptyString(value: unknown): string | undefined { - return typeof value === "string" && value.trim().length > 0 - ? value - : undefined; -} - -function firstNonEmptyString(...values: unknown[]): string | undefined { - for (const value of values) { - const text = nonEmptyString(value); - if (text) return text; - } - return undefined; -} - -function extractPromptFromChildEvents( - events: readonly SessionEvent[] -): string | undefined { - for (const event of events) { - if (event.source !== "user") continue; - const text = nonEmptyString(event.displayText); - if (text) return text; - } - return undefined; -} - function extractSubagentData(props: UniversalEventProps) { const { args, result } = props; @@ -77,12 +57,11 @@ function extractSubagentData(props: UniversalEventProps) { elapsedMs: sub.elapsedMs, success: sub.success, errorMessage: sub.errorMessage ?? fallbackErrorMessage, - prompt: firstNonEmptyString( + prompt: firstSubagentAssignmentPrompt( sub.prompt, args.prompt, args.instructions, - args.task, - result.prompt + args.task ), }; } @@ -105,11 +84,10 @@ function extractSubagentData(props: UniversalEventProps) { ? args.subagentSessionId : undefined; - const prompt = firstNonEmptyString( + const prompt = firstSubagentAssignmentPrompt( args.prompt, args.instructions, - args.task, - result.prompt + args.task ); const success = @@ -136,13 +114,18 @@ export const SubagentAdapter: React.FC = (props) => { ) ); const childPrompt = useMemo( - () => extractPromptFromChildEvents(childEvents), + () => extractSubagentPromptFromChildEvents(childEvents), [childEvents] ); const prompt = data.prompt ?? childPrompt; + const hasPrompt = Boolean(prompt && prompt.trim().length > 0); + const isAwaitingPrompt = + Boolean(data.subagentSessionId) && !hasPrompt && childEvents.length === 0; const setFocusedCell = useSetAtom(focusedSubagentCellAtom); const setPanelReveal = useSetAtom(subagentPanelRevealRequestAtom); + const setChatPanelMaximized = useSetAtom(chatPanelMaximizedAtom); + const setStationMode = useSetAtom(stationModeAtom); const navigateToEvent = useSetAtom(navigateToEventAtom); const handleNavigate = useCallback(() => { if (!data.subagentSessionId) return; @@ -154,14 +137,18 @@ export const SubagentAdapter: React.FC = (props) => { // also flips replayMode to "replay" (free-browse), pausing tail-follow at // that moment. The cell then re-materialises and focus/reveal take effect. navigateToEvent(props.eventId); + setStationMode("agent-station"); + setChatPanelMaximized(false); setFocusedCell(data.subagentSessionId); setPanelReveal((prev) => prev + 1); }, [ data.subagentSessionId, props.eventId, navigateToEvent, + setChatPanelMaximized, setFocusedCell, setPanelReveal, + setStationMode, ]); return ( @@ -172,7 +159,8 @@ export const SubagentAdapter: React.FC = (props) => { resultContent={data.resultContent} resultSummary={data.resultSummary} isLoading={ - props.status === "running" && props.showActiveEventPainting === true + isAwaitingPrompt || + (props.status === "running" && props.showActiveEventPainting === true) } defaultCollapsed={true} elapsedMs={data.elapsedMs} @@ -183,6 +171,7 @@ export const SubagentAdapter: React.FC = (props) => { errorMessage={data.errorMessage} eventId={props.eventId} onNavigate={data.subagentSessionId ? handleNavigate : undefined} + toolUsage={props.toolUsage} />
); diff --git a/src/engines/ChatPanel/rendering/adapters/TitleOnlyAdapter.codexWait.test.ts b/src/engines/ChatPanel/rendering/adapters/TitleOnlyAdapter.codexWait.test.ts new file mode 100644 index 000000000..88feace11 --- /dev/null +++ b/src/engines/ChatPanel/rendering/adapters/TitleOnlyAdapter.codexWait.test.ts @@ -0,0 +1,37 @@ +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; + +import type { RecipeRendererProps } from "../RecipeRenderer"; +import { RecipeRenderer } from "../RecipeRenderer"; + +vi.mock("@src/engines/ChatPanel/hooks/useChatEventReplay", () => ({ + useChatEventReplay: () => ({ + replayEventById: vi.fn(), + canReplay: false, + }), +})); + +describe("TitleOnlyAdapter Codex wait rendering", () => { + it("renders Codex wait payloads with the dedicated wait lifecycle", () => { + const props: RecipeRendererProps = { + event_id: "event-codex-wait", + functionName: "await_output", + uiCanonical: "await_output", + action_type: "tool_call", + args: { cell_id: "12", max_tokens: 4000, yield_time_ms: 1000 }, + result: { + observation: + "Script running with cell ID 12\nWall time 1.0 seconds\nOutput:", + }, + status: "completed", + }; + + const markup = renderToStaticMarkup(createElement(RecipeRenderer, props)); + + expect(markup).toContain('data-tool-call-name="await_output"'); + expect(markup).toContain("tools.awaitOutputDone"); + expect(markup).not.toContain("cell_id"); + expect(markup).not.toContain("Script running with cell ID"); + }); +}); diff --git a/src/engines/ChatPanel/rendering/adapters/TitleOnlyAdapter.tsx b/src/engines/ChatPanel/rendering/adapters/TitleOnlyAdapter.tsx index 48c1830c6..67e9ad836 100644 --- a/src/engines/ChatPanel/rendering/adapters/TitleOnlyAdapter.tsx +++ b/src/engines/ChatPanel/rendering/adapters/TitleOnlyAdapter.tsx @@ -66,12 +66,34 @@ function resolveAwaitCommand( // it". Only a non-null present value counts as "wait_for intent". const hasPattern = args?.pattern !== undefined && args?.pattern !== null; const hasWaitMode = args?.wait_mode !== undefined && args?.wait_mode !== null; - if (hasPattern || hasWaitMode) { + const isCodexWait = + args?.cell_id !== undefined || args?.yield_time_ms !== undefined; + if (hasPattern || hasWaitMode || isCodexWait) { return "wait_for"; } return "monitor"; } +/** Read Codex's wall-time line, falling back to the requested yield window. */ +function readCodexWaitedMs( + args: Record | undefined, + result: Record | undefined +): number | undefined { + if (args?.cell_id === undefined && args?.yield_time_ms === undefined) { + return undefined; + } + + for (const value of [result?.output, result?.content, result?.observation]) { + if (typeof value !== "string") continue; + const match = value.match(/Wall time\s+([\d.]+)\s+seconds?/i); + if (match) return Number.parseFloat(match[1]) * 1000; + } + + return typeof args?.yield_time_ms === "number" + ? args.yield_time_ms + : undefined; +} + /** Format a remaining-ms value for `Waiting {{countdown}} ...` titles. */ function formatRemaining(ms: number): string { const secs = Math.max(0, Math.ceil(ms / 1000)); @@ -173,7 +195,8 @@ function useAwaitExtras( // Countdown must be called unconditionally (rules of hooks), so pull the // inputs before the early return for non-await tools. Only `wait_for` // shows a countdown — for other commands we pass `undefined` to no-op. - const blockUntilMs = props.args?.block_until_ms as number | undefined; + const blockUntilMs = (props.args?.block_until_ms ?? + props.args?.yield_time_ms) as number | undefined; const countdownActive = awaitCommand === "wait_for" && lifecycle === "running"; const countdown = useCountdownString( @@ -190,7 +213,8 @@ function useAwaitExtras( const items = meta?.items ?? []; const representative = items.find((it) => it.status !== "running") ?? items[0]; - const waitedMs = representative?.waitedMs; + const waitedMs = + representative?.waitedMs ?? readCodexWaitedMs(props.args, props.result); const summary = buildSummary(items, t); @@ -265,6 +289,7 @@ export const TitleOnlyAdapter: React.FC = (props) => { } isFailed={state === "failed"} eventId={props.eventId} + toolUsage={props.toolUsage} /> ); diff --git a/src/engines/ChatPanel/rendering/adapters/TodoAdapter.test.ts b/src/engines/ChatPanel/rendering/adapters/TodoAdapter.test.ts new file mode 100644 index 000000000..5d11c0478 --- /dev/null +++ b/src/engines/ChatPanel/rendering/adapters/TodoAdapter.test.ts @@ -0,0 +1,72 @@ +import { Provider, createStore } from "jotai"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; + +import type { UniversalEventProps } from "@src/engines/SessionCore/rendering/types/universalProps"; +import { sessionTodoMapAtom } from "@src/store/ui/todoAtom"; + +import { TodoAdapter } from "./TodoAdapter"; + +vi.mock("../../blocks/TodoBlock", async () => { + const React = await import("react"); + return { + default: ({ todos }: { todos: Array<{ content: string }> }) => + React.createElement( + "div", + { "data-testid": "todo-labels" }, + todos.map((todo) => todo.content).join("|") + ), + }; +}); + +vi.mock("@src/engines/SessionCore/rendering/registry", () => ({ + statusToLifecycle: () => "success", + useLifecycleLabels: () => ({ success: "Updated to-do" }), +})); + +describe("TodoAdapter", () => { + it("backfills an empty event title from the reconstructed session todo state", () => { + const store = createStore(); + store.set( + sessionTodoMapAtom, + new Map([ + [ + "session-1", + { + todos: [ + { + id: "0", + content: "Review repository structure", + status: "completed" as const, + }, + ], + isUpdating: false, + lastUpdatedAt: null, + isVisible: true, + }, + ], + ]) + ); + + const props: UniversalEventProps = { + eventId: "todo-update", + eventType: "manage_todo", + functionName: "manage_todo", + sessionId: "session-1", + args: { action: "update", index: 0, status: "completed" }, + result: { + todos: [{ id: "0", content: "", status: "completed" }], + }, + status: "success", + variant: "chat", + context: "chat", + }; + + const markup = renderToStaticMarkup( + createElement(Provider, { store }, createElement(TodoAdapter, props)) + ); + + expect(markup).toContain("Review repository structure"); + }); +}); diff --git a/src/engines/ChatPanel/rendering/adapters/TodoAdapter.tsx b/src/engines/ChatPanel/rendering/adapters/TodoAdapter.tsx index 1d38f0018..d3c095c2c 100644 --- a/src/engines/ChatPanel/rendering/adapters/TodoAdapter.tsx +++ b/src/engines/ChatPanel/rendering/adapters/TodoAdapter.tsx @@ -3,6 +3,7 @@ * `null` when the extracted todo list is empty (the block would show * nothing useful) so the chat timeline stays tight. */ +import { useAtomValue } from "jotai"; import React from "react"; import { extractTodoData } from "@src/engines/SessionCore/rendering/props/propsDataExtractors"; @@ -11,12 +12,17 @@ import { useLifecycleLabels, } from "@src/engines/SessionCore/rendering/registry"; import type { UniversalEventProps } from "@src/engines/SessionCore/rendering/types/universalProps"; +import { getTodosForSession, sessionTodoMapAtom } from "@src/store/ui/todoAtom"; +import { preserveTodoContent } from "@src/store/ui/todoMerge"; import TodoBlock from "../../blocks/TodoBlock"; export const TodoAdapter: React.FC = (props) => { const action = (props.args?.action as string) || undefined; - const { todos, wasMerge } = extractTodoData(props); + const { todos: eventTodos, wasMerge } = extractTodoData(props); + const todoMap = useAtomValue(sessionTodoMapAtom); + const sessionTodos = getTodosForSession(todoMap, props.sessionId); + const todos = preserveTodoContent(sessionTodos, eventTodos); const labels = useLifecycleLabels(props.eventType, action); const state = statusToLifecycle(props.status); @@ -32,6 +38,7 @@ export const TodoAdapter: React.FC = (props) => { props.status === "running" && props.showActiveEventPainting === true } title={labels[state]} + toolUsage={props.toolUsage} /> ); diff --git a/src/engines/ChatPanel/rendering/adapters/WebSearchAdapter.tsx b/src/engines/ChatPanel/rendering/adapters/WebSearchAdapter.tsx index 750cfce03..4645d4af9 100644 --- a/src/engines/ChatPanel/rendering/adapters/WebSearchAdapter.tsx +++ b/src/engines/ChatPanel/rendering/adapters/WebSearchAdapter.tsx @@ -64,6 +64,7 @@ export const WebSearchAdapter: React.FC = (props) => { defaultCollapsed={true} eventId={props.eventId} title={title} + toolUsage={props.toolUsage} /> ); diff --git a/src/engines/ChatPanel/rendering/adapters/subagentPrompt.test.ts b/src/engines/ChatPanel/rendering/adapters/subagentPrompt.test.ts new file mode 100644 index 000000000..ae113efaa --- /dev/null +++ b/src/engines/ChatPanel/rendering/adapters/subagentPrompt.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; + +import { + extractSubagentPromptFromChildEvents, + firstSubagentAssignmentPrompt, +} from "./subagentPrompt"; + +const ASSIGNMENT_PROMPT = + "在当前工作目录下分析 Rust 源文件数量:统计所有 **/*.rs 文件,排除 target/ 目录;生成一份报告,包含总文件数、按目录分布、最大文件 Top 5,并在过程中持续汇报进展。"; + +describe("subagent prompt helpers", () => { + it("returns the first non-empty prompt without content guessing", () => { + expect(firstSubagentAssignmentPrompt("", " ", "Task")).toBe("Task"); + expect( + firstSubagentAssignmentPrompt( + undefined, + "pasted.txt [paste:paste://1782778711175-d8dsv8]" + ) + ).toBe("pasted.txt [paste:paste://1782778711175-d8dsv8]"); + }); + + it("extracts the first user prompt from child events", () => { + const prompt = extractSubagentPromptFromChildEvents([ + { + source: "assistant", + result: { content: "Now I have all the data." }, + displayText: "Now I have all the data.", + }, + { + source: "user", + result: { content: ASSIGNMENT_PROMPT }, + displayText: "Task", + }, + ]); + + expect(prompt).toContain("分析 Rust 源文件数量"); + expect(prompt).toContain("生成一份报告"); + }); +}); diff --git a/src/engines/ChatPanel/rendering/adapters/subagentPrompt.ts b/src/engines/ChatPanel/rendering/adapters/subagentPrompt.ts new file mode 100644 index 000000000..394370f08 --- /dev/null +++ b/src/engines/ChatPanel/rendering/adapters/subagentPrompt.ts @@ -0,0 +1,32 @@ +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; + +function nonEmptyString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 + ? value + : undefined; +} + +export function firstSubagentAssignmentPrompt( + ...values: unknown[] +): string | undefined { + for (const value of values) { + const text = nonEmptyString(value); + if (text) return text; + } + return undefined; +} + +export function extractSubagentPromptFromChildEvents( + events: readonly Pick[] +): string | undefined { + for (const event of events) { + if (event.source !== "user") continue; + const text = firstSubagentAssignmentPrompt( + event.result?.content, + event.result?.observation, + event.displayText + ); + if (text) return text; + } + return undefined; +} diff --git a/src/engines/ChatPanel/types.ts b/src/engines/ChatPanel/types.ts index fb4c57f0c..1b01a1c83 100644 --- a/src/engines/ChatPanel/types.ts +++ b/src/engines/ChatPanel/types.ts @@ -1,5 +1,6 @@ import type { ComponentType, ReactNode } from "react"; +import type { CliAgentType } from "@src/api/types/keys"; import type { SessionLaunchSuccessInfo, SessionLaunchWorkItemContext, @@ -12,6 +13,21 @@ export interface ChatPanelRegionNotice { body: string; } +export interface ChatPanelCliTerminalLaunchOptions { + cliAgentType: CliAgentType; + command: string; + title: string; + cwd?: string; + expectedProcess?: string; + /** + * Managed `code_sessions` row backing this TUI launch (`runner = 'tui'`). + * Injected into the PTY as `ORGII_SESSION_ID` so lifecycle hooks attribute + * status and transcripts to it; absent when session creation failed and + * the terminal runs unbound. + */ + agentSessionId?: string; +} + /** * Props for the main ChatPanel component */ @@ -46,12 +62,14 @@ export interface ChatPanelProps { variant?: "default" | "fullScreen"; centerFullScreenContent?: boolean; footerSlot?: ReactNode; + innerClassName?: string; leadingActionSlot?: ReactNode; hideRepoLine?: boolean; onRegionNoticeChange?: (notice: ChatPanelRegionNotice | null) => void; hidePresenceButton?: boolean; initialContent?: string; launchMode?: SessionCreatorLaunchMode; + onOpenCliTerminal?: (options: ChatPanelCliTerminalLaunchOptions) => void; onSessionStart?: (info: SessionLaunchSuccessInfo) => void; resolveWorkItemContext?: () => Promise; workItemContext?: SessionLaunchWorkItemContext; diff --git a/src/engines/SessionCore/ARCHITECTURE.md b/src/engines/SessionCore/ARCHITECTURE.md index 3050a5b36..41221edcf 100644 --- a/src/engines/SessionCore/ARCHITECTURE.md +++ b/src/engines/SessionCore/ARCHITECTURE.md @@ -244,6 +244,24 @@ merge them into a single event preserving both args and result. | `blocks/` | Reusable UI blocks (TodoBlock, etc.) | | `shared/` | Shared utilities and hooks | +## Chat History Projection Worker + +Chat history has three explicit data owners: + +| Layer | Owns | Must not own | +| ----------------------------------- | ---------------------------------------------------------------------------- | ------------------------------------------------------ | +| Rust EventStore / `DerivedSnapshot` | Durable events, event order, visibility, source version | React state, turn-collapse preferences | +| `ChatHistory/projection/` Worker | Rebuildable display projection, turn grouping/collapse, stable shape digests | Persistence, turn lifecycle/FSM, send/cancel semantics | +| React main thread | Jotai/React state, DOM, virtualization, scroll and user interaction | Full-history CPU projection for large sessions | + +The main entry point is `projectChatHistory()`. Both small-history main-thread execution and the module Worker call this same pure implementation. `projectChatGroups()` and `projectChatTurnPagination()` are also pure functions; their React hooks are adapters only. + +Worker messages use protocol version 1 and always carry `sessionId`, `generation`, `sourceVersion`, and `requestId`. The runtime accepts a snapshot followed by contiguous deltas. A missing session, generation mismatch, or source-version gap produces `resyncRequired`; stale responses are rejected by the client before React state is committed. The Worker caches at most four sessions and explicit disposal occurs on session unmount. + +Histories below 2,000 events stay on the main thread to avoid structured-clone and scheduling overhead. Larger histories use the Worker. If Worker construction, execution, or response validation fails, the UI falls back to the same pure core and records a warning without mutating EventStore state. Streaming token text remains in the existing leaf-renderer path and does not trigger a full projection for every token. + +`queueWaitMs`, `computeMs`, and `inputEvents` are response telemetry. These counters contain no message text, tool arguments, or other user content. Stable group/item digests replace render-time full-array `join()` keys. + ## lib/activityData vs ingestion (Rust) These are **different layers** with different purposes: diff --git a/src/engines/SessionCore/__tests__/registryCompleteness.test.ts b/src/engines/SessionCore/__tests__/registryCompleteness.test.ts index 904541bd8..a77dbb9ef 100644 --- a/src/engines/SessionCore/__tests__/registryCompleteness.test.ts +++ b/src/engines/SessionCore/__tests__/registryCompleteness.test.ts @@ -61,12 +61,7 @@ const RUST_TOOL_NAMES = { "manage_nodes", "manage_agent_def", ], - meta: [ - "suggest_mode_switch", - "suggest_next_steps", - "worktree", - "tool_search", - ], + meta: ["suggest_mode_switch", "worktree", "tool_search"], } as const; const ALL_RUST_TOOLS = Object.values(RUST_TOOL_NAMES).flat(); diff --git a/src/engines/SessionCore/control/sessionTimelineBoundary.test.ts b/src/engines/SessionCore/control/sessionTimelineBoundary.test.ts index 34b5c9c64..e6253970e 100644 --- a/src/engines/SessionCore/control/sessionTimelineBoundary.test.ts +++ b/src/engines/SessionCore/control/sessionTimelineBoundary.test.ts @@ -3,6 +3,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { CANCEL_REASON } from "@src/api/tauri/agent"; import { + BOUNDARY_EFFECTS, + type TimelineBoundaryReason, beginStopBoundary, cancelTurnForTimelineBoundary, isTimelineInterruptInFlight, @@ -172,6 +174,108 @@ describe("sessionTimelineBoundary", () => { }); }); + it("kills the running foreground shell for force-send boundaries (#110)", async () => { + interruptSpy.mockResolvedValue(undefined); + storeGetSpy.mockReturnValue( + new Map([ + [ + "session-1", + new Map([ + [ + 777, + { + pid: 777, + sessionId: "session-1", + command: "sleep 45", + status: "running", + startedAt: Date.now(), + }, + ], + ]), + ], + ]) + ); + + await cancelTurnForTimelineBoundary("session-1", "force-send"); + await Promise.resolve(); + + expect(killAgentShellProcessSpy).toHaveBeenCalledWith({ + pid: 777, + sessionId: "session-1", + }); + }); + + it("spares background shells on force-send boundaries (#110)", async () => { + interruptSpy.mockResolvedValue(undefined); + storeGetSpy.mockReturnValue( + new Map([ + [ + "session-1", + new Map([ + [ + 888, + { + pid: 888, + sessionId: "session-1", + command: "npm run dev", + status: "background", + startedAt: Date.now(), + }, + ], + ]), + ], + ]) + ); + + await cancelTurnForTimelineBoundary("session-1", "force-send"); + await Promise.resolve(); + + expect(killAgentShellProcessSpy).not.toHaveBeenCalled(); + }); + + it("kills no shell processes for rewind boundaries", async () => { + storeGetSpy.mockImplementation((atom: { debugLabel?: string }) => { + if (atom.debugLabel === "isSessionActive") return true; + if (atom.debugLabel === "sessionRuntimeStatus") return "running"; + if (atom.debugLabel === "session/sortedEvents") return []; + return new Map([ + [ + "session-1", + new Map([ + [ + 999, + { + pid: 999, + sessionId: "session-1", + command: "sleep 45", + status: "running", + startedAt: Date.now(), + }, + ], + ]), + ], + ]); + }); + interruptSpy.mockResolvedValue(undefined); + + await cancelTurnForTimelineBoundary("session-1", "rewind"); + await Promise.resolve(); + + expect(killAgentShellProcessSpy).not.toHaveBeenCalled(); + }); + + it("declares a boundary effect for every TimelineBoundaryReason", () => { + // Table-completeness guard: every boundary reason must have an explicit + // policy. `Record` enforces this at the type + // level; this asserts it at runtime so a future reason cannot ship without + // declaring its shell-kill / FSM behavior (the #110 latent-discipline gap). + const reasons: TimelineBoundaryReason[] = ["stop", "force-send", "rewind"]; + for (const reason of reasons) { + expect(BOUNDARY_EFFECTS[reason]).toBeDefined(); + } + expect(Object.keys(BOUNDARY_EFFECTS).sort()).toEqual([...reasons].sort()); + }); + it("closes local running events for Stop boundaries", async () => { getEventsSpy.mockResolvedValue([ { diff --git a/src/engines/SessionCore/control/sessionTimelineBoundary.ts b/src/engines/SessionCore/control/sessionTimelineBoundary.ts index 34d395e00..c416c2115 100644 --- a/src/engines/SessionCore/control/sessionTimelineBoundary.ts +++ b/src/engines/SessionCore/control/sessionTimelineBoundary.ts @@ -22,9 +22,53 @@ import { holdSessionQueueForStopAtom } from "@src/store/ui/messageQueueAtom"; import { getInstrumentedStore } from "@src/util/core/state/instrumentedStore"; import { streamingDeltaContentAtom } from "../core/atoms"; +import { discardStreamingDeltaBuffer } from "../core/atoms/streamingDeltaBuffer"; export type TimelineBoundaryReason = "stop" | "force-send" | "rewind"; +/** + * Which OS shell processes a boundary terminates. + * - "none": leave all shells running (rewind: the timeline is being + * rewritten, not stopped). + * - "foreground": kill running foreground shells, keep background workers + * (force-send: interrupt the current work but let long-running + * background processes / dev servers survive the follow-up). + * - "all": kill foreground + background (stop: user wants everything + * this session spawned to halt). + */ +type ShellKillScope = "none" | "foreground" | "all"; + +interface TimelineBoundaryEffect { + /** Drive the FSM to "stopping" (interrupt) vs "idle" (rewind rewrite). */ + forcesIdle: boolean; + /** Explicit user Stop — parks the queue and sets the cancel atoms. */ + isUserStop: boolean; + /** Which OS shell processes to terminate at this boundary. */ + shellKill: ShellKillScope; +} + +/** + * Single source of truth for every boundary's side-effects. Mirrors the + * backend's `CancelReason::boundary_effect()` struct: a new + * `TimelineBoundaryReason` cannot compile without declaring its policy here, + * so it can never silently inherit the wrong shell-kill / FSM behavior. That + * latent-discipline gap (an inline `if (isUserStop)` that only Stop satisfied) + * is exactly what let force-send leave a blocking foreground shell alive and + * swallow the follow-up message (issue #110). + */ +export const BOUNDARY_EFFECTS: Record< + TimelineBoundaryReason, + TimelineBoundaryEffect +> = { + stop: { forcesIdle: false, isUserStop: true, shellKill: "all" }, + "force-send": { + forcesIdle: false, + isUserStop: false, + shellKill: "foreground", + }, + rewind: { forcesIdle: true, isUserStop: false, shellKill: "none" }, +}; + const log = createLogger("sessionTimelineBoundary"); const interruptInFlightByBoundary = new Set(); @@ -46,6 +90,9 @@ export function clearLiveStreamingForSession(sessionId: string): void { const store = getInstrumentedStore(); markSessionStreamingStopped(sessionId); store.set(streamRetryStatusAtom, null); + // Cancel buffered chunks first so a queued trailing flush cannot + // resurrect the content cleared below. + discardStreamingDeltaBuffer(sessionId); store.set(streamingDeltaContentAtom, (prev) => { if (!prev.has(sessionId)) return prev; const next = new Map(prev); @@ -54,9 +101,11 @@ export function clearLiveStreamingForSession(sessionId: string): void { }); } -async function killActiveShellProcessesForStop( - sessionId: string +async function killShellProcessesForBoundary( + sessionId: string, + scope: ShellKillScope ): Promise { + if (scope === "none") return; const processes = getInstrumentedStore() .get(shellProcessMapAtom) .get(sessionId); @@ -64,9 +113,10 @@ async function killActiveShellProcessesForStop( await Promise.allSettled( [...processes.values()] - .filter( - (process) => - process.status === "running" || process.status === "background" + .filter((process) => + scope === "all" + ? process.status === "running" || process.status === "background" + : process.status === "running" ) .map((process) => killAgentShellProcess({ pid: process.pid, sessionId })) ); @@ -112,22 +162,22 @@ export function beginTimelineBoundary( reason: TimelineBoundaryReason ): void { const store = getInstrumentedStore(); - const isUserStop = reason === "stop"; + const effect = BOUNDARY_EFFECTS[reason]; // Drive the turn-lifecycle FSM first so any submit/dispatch racing this // boundary already observes the new phase. - // - stop / force-send: the turn stays blocked ("stopping") until the - // provider confirms the cancel with a terminal (bounded by the FSM's - // stopping dead-man). - // - rewind: the timeline is being rewritten — force idle immediately and - // invalidate any in-flight terminal of the overridden turn. - if (reason === "rewind") { + // - stop / force-send (forcesIdle:false): the turn stays blocked + // ("stopping") until the provider confirms the cancel with a terminal + // (bounded by the FSM's stopping dead-man). + // - rewind (forcesIdle:true): the timeline is being rewritten — force idle + // immediately and invalidate any in-flight terminal of the overridden turn. + if (effect.forcesIdle) { forceTurnIdle(sessionId); } else { beginTurnStopping(sessionId); } - if (isUserStop) { + if (effect.isUserStop) { store.set(userInitiatedCancelAtom, true); store.set(isPendingCancelAtom, true); // Stop parks every queued follow-up of this session: the natural drain @@ -139,14 +189,20 @@ export function beginTimelineBoundary( } clearLiveStreamingForSession(sessionId); - if (isUserStop) { - void killActiveShellProcessesForStop(sessionId).catch((error) => { + // Shell-kill scope is declared per-boundary in BOUNDARY_EFFECTS: + // stop="all", force-send="foreground", rewind="none". Killing the + // blocking foreground shell on force-send is the #110 fix — it lets the + // provider deliver the cancelled terminal promptly so the FSM flips idle + // and the queued Send-Now message dispatches, instead of stalling until + // the command finishes on its own. + void killShellProcessesForBoundary(sessionId, effect.shellKill).catch( + (error) => { log.warn( - "[sessionTimelineBoundary] failed to kill active shell processes", + "[sessionTimelineBoundary] failed to kill shell processes", error ); - }); - } + } + ); // All boundary causes close the interrupted turn's running events. For // force-send this is what clears stale `displayStatus:"running"` rows that // would otherwise keep `anyRunning` true in usePlanningIndicator and diff --git a/src/engines/SessionCore/core/__tests__/runningEventGate.test.ts b/src/engines/SessionCore/core/__tests__/runningEventGate.test.ts index 15bcd322a..7043d457c 100644 --- a/src/engines/SessionCore/core/__tests__/runningEventGate.test.ts +++ b/src/engines/SessionCore/core/__tests__/runningEventGate.test.ts @@ -1,9 +1,12 @@ import { describe, expect, it } from "vitest"; import { + classifyLatestTurnActivity, hasLiveRuntimeResourceInLatestTurn, + hasRunningAwaitWaitForInLatestTurn, isLiveRuntimeResourceEvent, sessionHasComposerStopBlockingWork, + shellProcessStatusFromArgs, } from "../runningEventGate"; import type { SessionEvent } from "../types"; @@ -41,6 +44,43 @@ function hiddenStatusEvent(): SessionEvent { } as unknown as SessionEvent; } +describe("shellProcessStatusFromArgs", () => { + it("returns undefined for falsy args", () => { + expect(shellProcessStatusFromArgs(undefined)).toBeUndefined(); + expect(shellProcessStatusFromArgs(null)).toBeUndefined(); + expect(shellProcessStatusFromArgs("")).toBeUndefined(); + }); + + it("extracts shellProcessStatus from object args", () => { + expect(shellProcessStatusFromArgs({ shellProcessStatus: "running" })).toBe( + "running" + ); + }); + + it("returns undefined without parsing when field name is absent from string args (fast bail)", () => { + expect( + shellProcessStatusFromArgs('{"command":"ls","exitCode":0}') + ).toBeUndefined(); + }); + + it("parses string args and returns status when shellProcessStatus field is present", () => { + expect( + shellProcessStatusFromArgs('{"shellProcessStatus":"running","pid":42}') + ).toBe("running"); + }); + + it("returns undefined for malformed JSON string that contains the field name", () => { + expect( + shellProcessStatusFromArgs('{"shellProcessStatus": broken json') + ).toBeUndefined(); + }); + + it("returns undefined for non-string non-object args", () => { + expect(shellProcessStatusFromArgs(42)).toBeUndefined(); + expect(shellProcessStatusFromArgs(true)).toBeUndefined(); + }); +}); + describe("runningEventGate", () => { it("classifies background shell as live resource only", () => { const events = [shellEvent("background")]; @@ -123,10 +163,11 @@ function settledToolEvent(id: string): SessionEvent { } function awaitOutputEvent( - displayStatus: "running" | "completed" + displayStatus: "running" | "completed", + command: "wait_for" | "monitor" = "wait_for" ): SessionEvent { return { - id: `await-${displayStatus}`, + id: `await-${command}-${displayStatus}`, sessionId: "session-1", source: "assistant", createdAt: new Date().toISOString(), @@ -135,7 +176,7 @@ function awaitOutputEvent( uiCanonical: "await_output", displayStatus, displayVariant: "tool_call", - args: { command: "wait_for", handles: ["pid-123"] }, + args: { command, handles: ["pid-123"] }, } as unknown as SessionEvent; } @@ -181,9 +222,12 @@ describe("hasLiveRuntimeResourceInLatestTurn", () => { expect(hasLiveRuntimeResourceInLatestTurn(events)).toBe(true); }); - it("exempts running await_output from suppressing the planning footer", () => { + it("treats a running await_output wait_for as live activity (watchdog must not kill it)", () => { + // Under the unified model a blocked wait IS genuine activity, so the + // watchdog input is true. The footer is hidden separately via the + // selfIndicating classification, not by pretending nothing is live. const events = [userEvent("u1"), awaitOutputEvent("running")]; - expect(hasLiveRuntimeResourceInLatestTurn(events)).toBe(false); + expect(hasLiveRuntimeResourceInLatestTurn(events)).toBe(true); }); it("still detects other running tools alongside a running await_output", () => { @@ -195,3 +239,65 @@ describe("hasLiveRuntimeResourceInLatestTurn", () => { expect(hasLiveRuntimeResourceInLatestTurn(events)).toBe(true); }); }); + +describe("classifyLatestTurnActivity (single source of truth)", () => { + it("idle when the latest turn is fully settled", () => { + expect( + classifyLatestTurnActivity([userEvent("u1"), settledToolEvent("t1")]) + ).toBe("idle"); + }); + + it("selfIndicating for a running wait_for (its own countdown is the indicator)", () => { + expect( + classifyLatestTurnActivity([userEvent("u1"), awaitOutputEvent("running")]) + ).toBe("selfIndicating"); + }); + + it("liveSilent for a running shell (needs the footer to convey activity)", () => { + expect( + classifyLatestTurnActivity([userEvent("u1"), shellEvent("running")]) + ).toBe("liveSilent"); + }); + + it("selfIndicating for a running monitor await (renders its own shimmer UI)", () => { + expect( + classifyLatestTurnActivity([ + userEvent("u1"), + awaitOutputEvent("running", "monitor"), + ]) + ).toBe("selfIndicating"); + }); + + it("a running wait_for dominates a sibling silent resource", () => { + expect( + classifyLatestTurnActivity([ + userEvent("u1"), + shellEvent("running"), + awaitOutputEvent("running"), + ]) + ).toBe("selfIndicating"); + }); + + // The invariant that the unification exists to guarantee: the two derived + // booleans agree by construction — `selfIndicating` ALWAYS implies the + // footer is suppressed AND the watchdog sees live activity, and they are + // never both reasoning about await_output in opposite directions. + it("derived booleans are consistent with the classification (no conflict)", () => { + const cases: SessionEvent[][] = [ + [userEvent("u1"), settledToolEvent("t1")], + [userEvent("u1"), shellEvent("running")], + [userEvent("u1"), awaitOutputEvent("running")], + [userEvent("u1"), awaitOutputEvent("running", "monitor")], + [userEvent("u1"), shellEvent("running"), awaitOutputEvent("running")], + ]; + for (const events of cases) { + const kind = classifyLatestTurnActivity(events); + const live = hasLiveRuntimeResourceInLatestTurn(events); + const selfIndicating = hasRunningAwaitWaitForInLatestTurn(events); + expect(live).toBe(kind !== "idle"); + expect(selfIndicating).toBe(kind === "selfIndicating"); + // selfIndicating ⇒ live (a self-indicating wait is, by definition, live). + if (selfIndicating) expect(live).toBe(true); + } + }); +}); diff --git a/src/engines/SessionCore/core/atoms/__tests__/actions.test.ts b/src/engines/SessionCore/core/atoms/__tests__/actions.test.ts index 8ae21f7a3..67454072c 100644 --- a/src/engines/SessionCore/core/atoms/__tests__/actions.test.ts +++ b/src/engines/SessionCore/core/atoms/__tests__/actions.test.ts @@ -12,8 +12,11 @@ import type { eventsAtom as EventsAtomType } from "../events"; vi.mock("../../store/EventStoreProxy", () => ({ eventStoreProxy: { append: vi.fn().mockResolvedValue(undefined), + set: vi.fn().mockResolvedValue(undefined), mergeEvents: vi.fn().mockResolvedValue(undefined), removeSyntheticUserInputEvents: vi.fn().mockResolvedValue(0), + scheduleSessionSnapshotRelease: vi.fn(), + cancelScheduledSnapshotRelease: vi.fn(), }, })); @@ -43,8 +46,11 @@ beforeAll(async () => { beforeEach(() => { vi.mocked(eventStoreProxy.append).mockClear(); + vi.mocked(eventStoreProxy.set).mockClear(); vi.mocked(eventStoreProxy.mergeEvents).mockClear(); vi.mocked(eventStoreProxy.removeSyntheticUserInputEvents).mockClear(); + vi.mocked(eventStoreProxy.scheduleSessionSnapshotRelease).mockClear(); + vi.mocked(eventStoreProxy.cancelScheduledSnapshotRelease).mockClear(); }); function makeMessageEvent( @@ -177,6 +183,14 @@ describe("loadSessionAtom", () => { expect(store.get(eventsAtom).map((event) => event.sessionId)).toEqual([ "session-2", ]); + // Switching away schedules the outgoing session's snapshot release; + // the incoming session is rescued from any pending release. + expect(eventStoreProxy.scheduleSessionSnapshotRelease).toHaveBeenCalledWith( + "session-1" + ); + expect(eventStoreProxy.cancelScheduledSnapshotRelease).toHaveBeenCalledWith( + "session-2" + ); }); it("carries optimistic user images onto the persisted echo during load", () => { @@ -198,6 +212,156 @@ describe("loadSessionAtom", () => { expect(store.get(eventsAtom)[0].result?.images).toEqual(images); }); + /** + * Native-transcript replay user events normalize to functionName "user" + * (the alias map resolves the chunk's action_type "raw"; "user_message" + * itself is not an alias) under an imported-history id — never the + * synthetic's user-input-* id. + */ + function makeReplayEvent( + id: string, + content: string, + role: "user" | "assistant", + createdAt: string + ): SessionEvent { + return { + ...makeMessageEvent(id, "session-1", createdAt), + functionName: role === "user" ? "user" : "assistant_message", + uiCanonical: role === "user" ? "user" : "agent_message", + actionType: role === "user" ? "raw" : "assistant", + source: role, + displayText: content, + result: + role === "user" + ? { type: "user", message: { content, role: "user" } } + : { content, observation: content }, + }; + } + + it("replace: drops the pre-existing synthetic user event when the replay carries the same content under a different id", () => { + const store = createStore(); + const synthetic = makeUserMessageEvent("user-input-1", "fix the bug", { + synthetic: true, + }); + const streamed = makeMessageEvent( + "stream-msg-session-1", + "session-1", + "2026-05-16T00:00:02.000Z" + ); + const replayUser = makeReplayEvent( + "claudecodeapp-user-0", + "fix the bug", + "user", + "2026-05-16T00:00:01.000Z" + ); + const replayAssistant = makeReplayEvent( + "claudecodeapp-asst-1", + "done", + "assistant", + "2026-05-16T00:00:03.000Z" + ); + + // Live turn: synthetic user bubble + streamed assistant placeholder. + store.set(loadSessionAtom, { + sessionId: "session-1", + events: [synthetic, streamed], + }); + // Turn end: native-transcript reconcile replays the canonical parse. + store.set(loadSessionAtom, { + sessionId: "session-1", + events: [replayUser, replayAssistant], + replace: true, + }); + + expect(store.get(eventsAtom).map((event) => event.id)).toEqual([ + "claudecodeapp-user-0", + "claudecodeapp-asst-1", + ]); + // Replace loads must overwrite the Rust store (which still holds the + // synthetic + streamed placeholders), not merge into it. + expect(eventStoreProxy.set).toHaveBeenLastCalledWith( + store.get(eventsAtom), + "session-1" + ); + }); + + it("without replace, the same reload keeps existing events and merges the replay next to them", () => { + const store = createStore(); + const synthetic = makeUserMessageEvent("user-input-1", "fix the bug", { + synthetic: true, + }); + const streamed = makeMessageEvent( + "stream-msg-session-1", + "session-1", + "2026-05-16T00:00:02.000Z" + ); + const replayUser = makeReplayEvent( + "claudecodeapp-user-0", + "fix the bug", + "user", + "2026-05-16T00:00:01.000Z" + ); + const replayAssistant = makeReplayEvent( + "claudecodeapp-asst-1", + "done", + "assistant", + "2026-05-16T00:00:03.000Z" + ); + + store.set(loadSessionAtom, { + sessionId: "session-1", + events: [synthetic, streamed], + }); + store.set(loadSessionAtom, { + sessionId: "session-1", + events: [replayUser, replayAssistant], + }); + + // Current merge behavior: existing events survive (only the synthetic + // is stripped by the backend-user-message fallback) and the replay rows + // append after them; the Rust store is merged, not replaced. + expect(store.get(eventsAtom).map((event) => event.id)).toEqual([ + "stream-msg-session-1", + "claudecodeapp-user-0", + "claudecodeapp-asst-1", + ]); + expect(eventStoreProxy.set).not.toHaveBeenCalled(); + expect(eventStoreProxy.mergeEvents).toHaveBeenLastCalledWith( + store.get(eventsAtom), + "session-1" + ); + }); + + it("replace: still recovers the synthetic user event when the replay has no backend user message yet", () => { + const store = createStore(); + const synthetic = makeUserMessageEvent("user-input-1", "fix the bug", { + synthetic: true, + }); + const replayAssistant = makeReplayEvent( + "claudecodeapp-asst-0", + "working on it", + "assistant", + "2026-05-16T00:00:02.000Z" + ); + + store.set(loadSessionAtom, { + sessionId: "session-1", + events: [synthetic], + }); + store.set(loadSessionAtom, { + sessionId: "session-1", + events: [replayAssistant], + replace: true, + }); + + // First-message recovery: the native store has not flushed the user + // turn yet, so the synthetic bubble must not vanish. + expect(store.get(eventsAtom).map((event) => event.id)).toEqual([ + "user-input-1", + "claudecodeapp-asst-0", + ]); + }); + it("carries optimistic user images onto a live persisted echo", () => { const store = createStore(); const images = ["data:image/png;base64,BBB"]; diff --git a/src/engines/SessionCore/core/atoms/__tests__/streamingDeltaBuffer.test.ts b/src/engines/SessionCore/core/atoms/__tests__/streamingDeltaBuffer.test.ts new file mode 100644 index 000000000..ffe6fa654 --- /dev/null +++ b/src/engines/SessionCore/core/atoms/__tests__/streamingDeltaBuffer.test.ts @@ -0,0 +1,137 @@ +import type { SetStateAction } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { StreamingDeltaContent } from "../events"; +import { + bufferStreamingDelta, + clearStreamingDelta, + discardStreamingDeltaBuffer, + flushStreamingDeltas, +} from "../streamingDeltaBuffer"; + +type DeltaMap = Map; + +/** Applies setter updates to a live map and records every resulting state. */ +function createAtomHarness() { + const harness = { + map: new Map() as DeltaMap, + history: [] as DeltaMap[], + set: (update: SetStateAction) => { + harness.map = typeof update === "function" ? update(harness.map) : update; + harness.history.push(harness.map); + }, + }; + return harness; +} + +describe("streamingDeltaBuffer", () => { + beforeEach(() => { + vi.useFakeTimers(); + discardStreamingDeltaBuffer(); + }); + + afterEach(() => { + discardStreamingDeltaBuffer(); + vi.useRealTimers(); + }); + + it("flushes the first chunk of an idle session immediately (leading edge)", () => { + const harness = createAtomHarness(); + bufferStreamingDelta("s1", { kind: "message", content: "a" }, harness.set); + expect(harness.map.get("s1")).toEqual({ kind: "message", content: "a" }); + }); + + it("buffers subsequent chunks and trail-flushes at ~50ms with the newest content", () => { + const harness = createAtomHarness(); + bufferStreamingDelta("s1", { kind: "message", content: "a" }, harness.set); + bufferStreamingDelta("s1", { kind: "message", content: "ab" }, harness.set); + bufferStreamingDelta( + "s1", + { kind: "message", content: "abc" }, + harness.set + ); + // Still showing the leading-edge flush — no per-chunk atom writes. + expect(harness.map.get("s1")).toEqual({ kind: "message", content: "a" }); + + vi.advanceTimersByTime(49); + expect(harness.map.get("s1")).toEqual({ kind: "message", content: "a" }); + + vi.advanceTimersByTime(1); + expect(harness.map.get("s1")).toEqual({ kind: "message", content: "abc" }); + }); + + it("flushes the complete accumulated content on completion, then clears", () => { + const harness = createAtomHarness(); + bufferStreamingDelta("s1", { kind: "message", content: "a" }, harness.set); + bufferStreamingDelta("s1", { kind: "message", content: "ab" }, harness.set); + bufferStreamingDelta( + "s1", + { kind: "message", content: "abc" }, + harness.set + ); + + clearStreamingDelta("s1", harness.set); + + // The full accumulated content was written before the removal. + const contents = harness.history.map((state) => state.get("s1")?.content); + expect(contents).toContain("abc"); + expect(harness.map.has("s1")).toBe(false); + }); + + it("never resurrects a cleared session from a stale trailing flush", () => { + const harness = createAtomHarness(); + bufferStreamingDelta("s1", { kind: "message", content: "a" }, harness.set); + bufferStreamingDelta("s1", { kind: "message", content: "ab" }, harness.set); + + // Direct clear path (session switch / timeline boundary): discard the + // buffer, then clear the atom without going through clearStreamingDelta. + discardStreamingDeltaBuffer("s1"); + harness.set(new Map()); + const writesAfterClear = harness.history.length; + + vi.advanceTimersByTime(200); + flushStreamingDeltas(); + expect(harness.map.has("s1")).toBe(false); + expect(harness.history.length).toBe(writesAfterClear); + }); + + it("flushes immediately on a kind change so transitions render without lag", () => { + const harness = createAtomHarness(); + bufferStreamingDelta( + "s1", + { kind: "thinking", content: "reasoning" }, + harness.set + ); + bufferStreamingDelta( + "s1", + { kind: "thinking", content: "reasoning more" }, + harness.set + ); + // Pending thinking chunk is un-flushed; a kind change flushes now. + bufferStreamingDelta( + "s1", + { kind: "message", content: "answer" }, + harness.set + ); + expect(harness.map.get("s1")).toEqual({ + kind: "message", + content: "answer", + }); + }); + + it("keeps concurrent sessions independent within one flush", () => { + const harness = createAtomHarness(); + bufferStreamingDelta("s1", { kind: "message", content: "a" }, harness.set); + bufferStreamingDelta("s2", { kind: "thinking", content: "t" }, harness.set); + bufferStreamingDelta("s1", { kind: "message", content: "ab" }, harness.set); + bufferStreamingDelta( + "s2", + { kind: "thinking", content: "tt" }, + harness.set + ); + + vi.advanceTimersByTime(50); + expect(harness.map.get("s1")).toEqual({ kind: "message", content: "ab" }); + expect(harness.map.get("s2")).toEqual({ kind: "thinking", content: "tt" }); + }); +}); diff --git a/src/engines/SessionCore/core/atoms/actions.ts b/src/engines/SessionCore/core/atoms/actions.ts index 68f03ad1f..b3d311ed6 100644 --- a/src/engines/SessionCore/core/atoms/actions.ts +++ b/src/engines/SessionCore/core/atoms/actions.ts @@ -11,6 +11,7 @@ import { clearLoadedPayloads } from "@src/engines/SessionCore/payloads"; import { clearLoadedTurnRegistry } from "@src/engines/SessionCore/turns/loadedTurnRegistry"; import { createLogger } from "@src/hooks/logger"; import { messageQueueAtom } from "@src/store/ui/messageQueueAtom"; +import { isImportedHistorySession } from "@src/util/session/sessionDispatch"; import { isVisibleInChat } from "../../ingestion/visibilityFilters"; import { @@ -19,7 +20,11 @@ import { } from "../../sync/utils/activityIds"; import { isLiveRuntimeResourceEvent } from "../runningEventGate"; import { eventStoreProxy } from "../store/EventStoreProxy"; -import type { SessionEvent, SessionSpec } from "../types"; +import type { + SessionEvent, + SessionSpec, + SimulatorEventPreview, +} from "../types"; import { applyRunningArgs, extendRunningArgsCache, @@ -113,6 +118,96 @@ function isSimulatorVisibleApprox(event: SessionEvent): boolean { ); } +function getSimulatorFilterCategory( + event: SessionEvent +): SimulatorEventPreview["filterCategory"] { + if (event.source === "user") return "key_interactions"; + if ( + event.uiCanonical === "edit_file" || + event.uiCanonical === "delete_file" + ) { + return "file_changes"; + } + if (event.command || event.uiCanonical === "run_shell") { + return "terminal_events"; + } + if ( + event.uiCanonical === "read_file" || + event.uiCanonical === "list_dir" || + event.uiCanonical === "code_search" || + event.uiCanonical === "glob" || + event.uiCanonical === "find_files" || + event.uiCanonical === "search" + ) { + return "explore"; + } + if (event.filePath) return "file_changes"; + return "other"; +} + +function buildSimulatorPreview(event: SessionEvent): SimulatorEventPreview { + return { + id: event.id, + sessionId: event.sessionId, + createdAt: event.createdAt, + functionName: event.functionName, + uiCanonical: event.uiCanonical, + actionType: event.actionType, + source: event.source, + displayText: event.displayText, + displayStatus: event.displayStatus, + displayVariant: event.displayVariant, + activityStatus: event.activityStatus, + filterCategory: getSimulatorFilterCategory(event), + threadId: event.threadId, + processId: event.processId, + callId: event.callId, + filePath: event.filePath, + command: event.command, + isDelta: event.isDelta, + repoId: event.repoId, + repoPath: event.repoPath, + }; +} + +function buildSimulatorPreviewFields(events: SessionEvent[]): { + sortedSimulatorEventIds: string[]; + eventPreviewById: Record; + createdAtById: Record; + threadIdById: Record; + functionNameById: Record; + displayStatusById: Record; + displayVariantById: Record; +} { + const sortedSimulatorEventIds: string[] = []; + const eventPreviewById: Record = {}; + const createdAtById: Record = {}; + const threadIdById: Record = {}; + const functionNameById: Record = {}; + const displayStatusById: Record = {}; + const displayVariantById: Record = {}; + + for (const event of events) { + sortedSimulatorEventIds.push(event.id); + eventPreviewById[event.id] = buildSimulatorPreview(event); + createdAtById[event.id] = event.createdAt; + if (event.threadId) threadIdById[event.id] = event.threadId; + functionNameById[event.id] = event.functionName; + displayStatusById[event.id] = event.displayStatus; + displayVariantById[event.id] = event.displayVariant; + } + + return { + sortedSimulatorEventIds, + eventPreviewById, + createdAtById, + threadIdById, + functionNameById, + displayStatusById, + displayVariantById, + }; +} + function syntheticMatchesQueuedMessage( event: SessionEvent, queued: { sessionId: string; content: string; displayContent: string } @@ -162,6 +257,11 @@ export const clearSessionAtom = atom(null, (get, set) => { clearLoadedPayloads(); if (currentSessionId) { clearLoadedTurnRegistry(currentSessionId); + // Free the departing session's JS snapshot mirror (full event arrays, + // inflated further by any replay-loaded turn bodies) after a grace + // window, so rapid switch-backs stay warm. Skipped while it is still + // streaming; Rust remains the source of truth either way. + eventStoreProxy.scheduleSessionSnapshotRelease(currentSessionId); } // NOTE: Do NOT call set(eventsAtom, []) here. eventsAtom's write handler // fires eventStoreProxy.set([]) which is an async fire-and-forget IPC to @@ -200,12 +300,31 @@ interface LoadSessionPayload { events: SessionEvent[]; specs?: SessionSpec[]; isFromCache?: boolean; + /** + * When true and the session is already on screen, the incoming events are + * the canonical transcript: skip the base-events merge entirely and use + * them wholesale (the Rust EventStore is replaced too, not merged into). + * Used by the native-transcript reconcile — the CLI's own store is the + * transcript of record, so ephemeral in-memory turn events must not + * survive next to their replayed counterparts. + * + * Synthetic-preservation still applies when the incoming replay carries no + * backend user message (first-message recovery), and queued-synthetic + * filtering always runs. + */ + replace?: boolean; } export const loadSessionAtom = atom( null, (get, set, payload: LoadSessionPayload) => { - const { sessionId, events, specs = [], isFromCache = false } = payload; + const { + sessionId, + events, + specs = [], + isFromCache = false, + replace = false, + } = payload; // Preserve synthetic user events (injected by session launch) when the // sync hooks reload from SQLite/API before the backend has persisted the @@ -259,9 +378,15 @@ export const loadSessionAtom = atom( set(pendingSyntheticEventAtom, null); } resetSessionUIState(set, currentSessionId); + // Direct A→B switches come through here without clearSessionAtom — + // schedule the outgoing session's snapshot release here too. + eventStoreProxy.scheduleSessionSnapshotRelease(currentSessionId); } set(sessionIdAtom, sessionId); + // Belt-and-braces for load paths that skip switchSession(): the incoming + // session is active again, so rescue it from any pending release. + eventStoreProxy.cancelScheduledSnapshotRelease(sessionId); resetRunningArgsCache(); @@ -269,14 +394,36 @@ export const loadSessionAtom = atom( const existingIds = new Set( existingSameSessionEvents.map((event) => event.id) ); + // replace: the incoming events ARE the transcript — never merge them + // next to the existing in-memory events (whose ids never match, so the + // id-based merge would duplicate every replayed user/assistant turn). + const replaceForSession = replace && currentSessionId === sessionId; const baseEvents = - currentSessionId === sessionId && existingSameSessionEvents.length > 0 + !replaceForSession && + currentSessionId === sessionId && + existingSameSessionEvents.length > 0 ? existingSameSessionEvents : []; + // Imported transcripts are append-only and their parsed events immutable, + // so a refresh reload keeps the EXISTING object references for events the + // store already holds — the memoized render pipeline (sameFlatItems &co) + // then short-circuits on identity and only genuinely new rows render. + // The final existing event is the exception: chunk aggregation can still + // extend it while new lines append, so it takes the incoming version. + // Live sessions keep replace semantics: streaming events evolve in place + // under a stable id, and stale references would freeze their content. + const reuseExistingReferences = isImportedHistorySession(sessionId); const eventsForLoad = baseEvents.length > 0 ? [ - ...baseEvents.map((event) => incomingById.get(event.id) ?? event), + ...baseEvents.map((event, index) => { + const incoming = incomingById.get(event.id); + if (!incoming) return event; + if (reuseExistingReferences && index < baseEvents.length - 1) { + return event; + } + return incoming; + }), ...events.filter((event) => !existingIds.has(event.id)), ] : events; @@ -366,17 +513,20 @@ export const loadSessionAtom = atom( const eventIndex = Object.fromEntries( mergedEvents.map((event, index) => [event.id, index]) ); + const simulatorEvents = mergedEvents.filter(isSimulatorVisibleApprox); + const simulatorPreviewFields = buildSimulatorPreviewFields(simulatorEvents); set(derivedSnapshotAtom, { version: Date.now(), eventCount: mergedEvents.length, events: mergedEvents, chatEvents: mergedEvents.filter(isVisibleInChat), - messagesEvents: mergedEvents.filter(isSimulatorVisibleApprox), - sortedSimulatorEvents: mergedEvents.filter(isSimulatorVisibleApprox), + messagesEvents: simulatorEvents, + sortedSimulatorEvents: simulatorEvents, lastEvent: mergedEvents[mergedEvents.length - 1] ?? null, eventIndex, chatEventCount: mergedEvents.filter(isVisibleInChat).length, hasRunningEvent: mergedEvents.some(isLiveRuntimeResourceEvent), + ...simulatorPreviewFields, }); // Merge events into Rust EventStore with explicit sessionId. @@ -387,9 +537,19 @@ export const loadSessionAtom = atom( // and the cache-hit case (events come from getEvents() so they already // include live data — merging them back is a no-op dedup). // + // Exception: replace loads (native-transcript reconcile at a terminal + // turn status). There the Rust store still holds the ephemeral in-memory + // turn events (synthetic user bubble, streamed placeholders) whose ids + // never match the replayed transcript, so a merge would push a snapshot + // that reintroduces them as duplicates. The turn is over — set() carries + // no live-work race and is the intended "replace all" semantics. + // // Explicit sessionId avoids the "active session" fallback that crashes on // app restart when Rust has no active session but localStorage has a stale id. - eventStoreProxy.mergeEvents(mergedEvents, sessionId).catch((err) => { + const rustStoreWrite = replaceForSession + ? eventStoreProxy.set(mergedEvents, sessionId) + : eventStoreProxy.mergeEvents(mergedEvents, sessionId); + rustStoreWrite.catch((err) => { log.warn("[loadSession] Failed to sync events to Rust store:", err); }); set(specsAtom, specs); diff --git a/src/engines/SessionCore/core/atoms/actionsUtils.ts b/src/engines/SessionCore/core/atoms/actionsUtils.ts index dfb7f9332..a9c74c4ba 100644 --- a/src/engines/SessionCore/core/atoms/actionsUtils.ts +++ b/src/engines/SessionCore/core/atoms/actionsUtils.ts @@ -36,6 +36,7 @@ import { replayModeAtom, replayTimeRangeAtom, } from "./replay"; +import { discardStreamingDeltaBuffer } from "./streamingDeltaBuffer"; // ============================================ // Running args cache @@ -135,7 +136,10 @@ export function resetSessionUIState( set(sessionRuntimeStatusAtom, "idle"); set(sessionRuntimeErrorAtom, null); set(streamRetryStatusAtom, null); + // Cancel buffered chunks BEFORE clearing the atom so a queued trailing + // flush cannot resurrect the cleared session's streaming content. if (sessionId) { + discardStreamingDeltaBuffer(sessionId); set(streamingDeltaContentAtom, (prev) => { if (!prev.has(sessionId)) return prev; const next = new Map(prev); @@ -143,6 +147,7 @@ export function resetSessionUIState( return next; }); } else { + discardStreamingDeltaBuffer(); set(streamingDeltaContentAtom, new Map()); } } diff --git a/src/engines/SessionCore/core/atoms/events.ts b/src/engines/SessionCore/core/atoms/events.ts index 116b6445b..1a9d9f2a6 100644 --- a/src/engines/SessionCore/core/atoms/events.ts +++ b/src/engines/SessionCore/core/atoms/events.ts @@ -42,23 +42,33 @@ const log = createLogger("eventsAtom"); // ============================================ /** - * Per-session live assistant message content updated on every token delta. + * Per-session live stream content. * * Provides a direct rendering path that bypasses the EventStore snapshot * pipeline (16ms TS throttle → IPC → 33ms Rust batch → serialization → IPC - * back → React). Components that read this atom see each token immediately - * as it arrives from the LLM, giving smooth streaming UX. + * back → React). * - * Shape: Map so multiple sessions can stream concurrently - * (e.g. Control Tower with multiple agent sessions visible side by side). + * Shape: Map so message and thinking streams + * cannot be confused while multiple sessions stream concurrently. * - * Set by the `onStreamingDelta` callback in useSessionSync (keyed by sessionId). - * Cleared per-session on streaming_complete, session complete, and session switch. + * Set by the `onStreamingDelta` callback in useSessionSync (keyed by + * sessionId) through streamingDeltaBuffer.ts: chunks are buffered and land + * here on a trailing ~50ms flush (≤20Hz), with immediate flushes on stream + * start, kind change, and completion. Cleared per-session on + * streaming_complete, session complete, and session switch — clear paths + * must also discard the buffer (see streamingDeltaBuffer.ts). * Token-level live content must not be written to the durable EventStore. */ -export const streamingDeltaContentAtom = atom>( - new Map() -); +export type StreamingDeltaKind = "message" | "thinking"; + +export interface StreamingDeltaContent { + kind: StreamingDeltaKind; + content: string; +} + +export const streamingDeltaContentAtom = atom< + Map +>(new Map()); streamingDeltaContentAtom.debugLabel = "session/streamingDeltaContent"; // ============================================ diff --git a/src/engines/SessionCore/core/atoms/streamingDeltaBuffer.ts b/src/engines/SessionCore/core/atoms/streamingDeltaBuffer.ts new file mode 100644 index 000000000..8a6a99eb6 --- /dev/null +++ b/src/engines/SessionCore/core/atoms/streamingDeltaBuffer.ts @@ -0,0 +1,140 @@ +/** + * Write-buffer for `streamingDeltaContentAtom`. + * + * Token deltas arrive far faster than a per-token atom write can be + * justified: each write cloned the whole per-session Map and re-rendered + * every subscriber. Chunks are instead recorded synchronously in a + * module-level holder and flushed into the atom on a trailing ~50ms timer + * (≤20Hz). Every chunk carries the FULL accumulated content, so a flush + * always replaces wholesale — buffering never loses text. + * + * Synchronous-flush guarantees: + * - stream completion (`clearStreamingDelta`) flushes accumulated content + * first, then removes the entry — the last content write always carries + * the complete accumulated text; + * - a kind change (thinking ↔ message) flushes immediately so the + * transition renders without buffer lag; + * - the first chunk of an idle session flushes immediately (leading edge) + * so stream start is not delayed; + * - clear paths that write the atom directly (session switch, timeline + * boundary) MUST call `discardStreamingDeltaBuffer` too, or a queued + * trailing flush resurrects the cleared session's content. + */ +import type { SetStateAction } from "react"; + +import type { StreamingDeltaContent, StreamingDeltaKind } from "./events"; + +const FLUSH_INTERVAL_MS = 50; + +type SetStreamingDeltaContent = ( + update: SetStateAction> +) => void; + +/** + * sessionId → newest un-flushed content. Entries are mutated in place only + * until flushed; an object handed to the atom is never touched again. + */ +const pendingBySession = new Map(); +/** Kind currently visible to atom consumers, per session. */ +const deliveredKindBySession = new Map(); +let flushTimer: ReturnType | null = null; +let flushSetter: SetStreamingDeltaContent | null = null; + +function cancelFlushTimer(): void { + if (flushTimer === null) return; + clearTimeout(flushTimer); + flushTimer = null; +} + +function scheduleFlush(): void { + if (flushTimer !== null) return; + flushTimer = setTimeout(() => { + flushTimer = null; + flushStreamingDeltas(); + }, FLUSH_INTERVAL_MS); +} + +/** Write all buffered content into the atom now (one Map clone total). */ +export function flushStreamingDeltas(): void { + cancelFlushTimer(); + if (pendingBySession.size === 0 || !flushSetter) return; + const updates = new Map(pendingBySession); + pendingBySession.clear(); + for (const [sessionId, content] of updates) { + deliveredKindBySession.set(sessionId, content.kind); + } + flushSetter((prev) => { + const next = new Map(prev); + for (const [sessionId, content] of updates) { + next.set(sessionId, content); + } + return next; + }); +} + +/** + * Record a streaming chunk. `content.content` must be the full accumulated + * text for the session (the adapters already stream cumulatively). + */ +export function bufferStreamingDelta( + sessionId: string, + content: StreamingDeltaContent, + setStreamingDeltaContent: SetStreamingDeltaContent +): void { + flushSetter = setStreamingDeltaContent; + const pending = pendingBySession.get(sessionId); + if (pending && pending.kind === content.kind) { + pending.content = content.content; + scheduleFlush(); + return; + } + pendingBySession.set(sessionId, { + kind: content.kind, + content: content.content, + }); + const visibleKind = pending?.kind ?? deliveredKindBySession.get(sessionId); + if (visibleKind !== content.kind) { + // Leading edge (stream start) and kind changes render immediately. + flushStreamingDeltas(); + return; + } + scheduleFlush(); +} + +/** + * Stream completion / interruption for a session: flush the complete + * accumulated content, then remove the session's entry from the atom. + */ +export function clearStreamingDelta( + sessionId: string, + setStreamingDeltaContent: SetStreamingDeltaContent +): void { + flushSetter = setStreamingDeltaContent; + flushStreamingDeltas(); + deliveredKindBySession.delete(sessionId); + setStreamingDeltaContent((prev) => { + if (!prev.has(sessionId)) return prev; + const next = new Map(prev); + next.delete(sessionId); + return next; + }); +} + +/** + * Drop buffered content and cancel the pending flush WITHOUT writing the + * atom. For callers that clear `streamingDeltaContentAtom` themselves; a + * later flush must not re-insert the cleared session's content. Omitting + * `sessionId` discards every session (full reset path). + */ +export function discardStreamingDeltaBuffer(sessionId?: string): void { + if (sessionId === undefined) { + pendingBySession.clear(); + deliveredKindBySession.clear(); + } else { + pendingBySession.delete(sessionId); + deliveredKindBySession.delete(sessionId); + } + if (pendingBySession.size === 0) { + cancelFlushTimer(); + } +} diff --git a/src/engines/SessionCore/core/runningEventGate.ts b/src/engines/SessionCore/core/runningEventGate.ts index d71dc0eef..4c1f3c142 100644 --- a/src/engines/SessionCore/core/runningEventGate.ts +++ b/src/engines/SessionCore/core/runningEventGate.ts @@ -27,6 +27,7 @@ export function shellProcessStatusFromArgs(args: unknown): string | undefined { return (args as { shellProcessStatus?: string }).shellProcessStatus; } if (typeof args !== "string") return undefined; + if (!args.includes("shellProcessStatus")) return undefined; try { const parsed = JSON.parse(args) as { shellProcessStatus?: string }; return parsed.shellProcessStatus; @@ -54,36 +55,66 @@ export function isLiveRuntimeResourceEvent(event: SessionEvent): boolean { } /** - * Planning-footer variant of the live-resource scan: only the LATEST turn - * (events after the last user-source message) counts. + * The latest turn's live-activity classification — the SINGLE source of truth + * for "is the agent visibly working, and does that work already show its own + * indicator?". Both `hasLiveRuntimeResourceInLatestTurn` (watchdog input) and + * `hasRunningAwaitWaitForInLatestTurn` (footer suppression) are derived from + * this one scan, so they can never disagree about how `await_output` is + * treated — the previous two-independent-scans design reasoned about + * await_output in OPPOSITE directions (one excluded it "so the footer shows", + * the other matched it "so the footer hides"), which only happened to compose + * correctly. Modelling it once removes that latent conflict. * - * Why not the whole session: zombie running events — tool calls whose - * terminal status merge was dropped, or shell events whose - * `shellProcessStatus` froze at "running" after the process exited — are - * permanent once persisted. Scanning the full history lets one zombie from - * an old turn suppress the "Planning next step…" footer for every later - * turn in the session. Old-turn background shells (dev servers) are also - * deliberately excluded: a pinned background process is not a reason to - * hide "the agent is thinking". + * - `idle` — no live runtime resource in the latest turn. + * - `selfIndicating` — any running `await_output` (both `wait_for` and + * `monitor`): each renders its own shimmer UI, which IS the activity + * indicator, so the planning footer would be a redundant second one. + * - `liveSilent` — a running resource (shell, etc.) with no self-evident + * indicator of its own; the planning footer is the thing that conveys "still + * alive", so it should stay. * - * Within the current turn the gate keeps its meaning: a genuinely running - * row paints its own shimmer, so the footer stays hidden. - * - * `await_output` is exempt: it polls/blocks waiting for OTHER jobs (shell - * processes, subagents) and renders as a subtle TitleOnlyBlock whose - * shimmer is too faint to convey activity. The planning footer is a - * better signal that the agent is still alive during a long wait_for. + * Scoped to the latest turn (events after the last user-source message) to + * avoid zombie running rows from older turns — tool calls whose terminal + * status merge was dropped, or shells whose `shellProcessStatus` froze at + * "running" after exit. Old-turn background shells (dev servers) are likewise + * excluded: a pinned background process is not a reason to change the footer. */ -export function hasLiveRuntimeResourceInLatestTurn( +export type LatestTurnActivity = "idle" | "selfIndicating" | "liveSilent"; + +export function classifyLatestTurnActivity( events: readonly SessionEvent[] -): boolean { +): LatestTurnActivity { + let sawLiveSilent = false; for (let i = events.length - 1; i >= 0; i--) { const event = events[i]; - if (event.source === "user") return false; - if (isLiveRuntimeResourceEvent(event) && !isAwaitOutputEvent(event)) - return true; + if (event.source === "user") break; + if (!isLiveRuntimeResourceEvent(event)) continue; + if (isAwaitOutputEvent(event)) { + // Any running await_output (wait_for or monitor) self-indicates: it + // renders its own shimmer UI, so the planning footer would be a + // redundant second indicator. Stop scanning — self-indicating dominates. + return "selfIndicating"; + } + // A running resource without its own indicator (e.g. a shell). + sawLiveSilent = true; } - return false; + return sawLiveSilent ? "liveSilent" : "idle"; +} + +/** + * True when the latest turn has any live runtime resource — used by the + * planning-indicator watchdog so it does not force-complete a session that is + * genuinely still working (a long `wait_for`, a running shell, …). + * + * Unlike the pre-unification version, this now INCLUDES a running `wait_for`: + * a blocked wait is genuine activity, so the watchdog should not kill it. The + * footer is suppressed during a wait_for via `hasRunningAwaitWaitForInLatestTurn` + * (the `selfIndicating` case), not by pretending no resource is live. + */ +export function hasLiveRuntimeResourceInLatestTurn( + events: readonly SessionEvent[] +): boolean { + return classifyLatestTurnActivity(events) !== "idle"; } function isAwaitOutputEvent(event: SessionEvent): boolean { @@ -93,6 +124,18 @@ function isAwaitOutputEvent(event: SessionEvent): boolean { ); } +/** + * True when the latest turn's activity is self-indicating — i.e. a still-running + * `await_output` (either `wait_for` or `monitor`) whose own shimmer UI already + * conveys "the agent is alive". Callers suppress the planning footer in this + * window so the user does not see two stacked waiting indicators. + */ +export function hasRunningAwaitWaitForInLatestTurn( + events: readonly SessionEvent[] +): boolean { + return classifyLatestTurnActivity(events) === "selfIndicating"; +} + export function isTurnBlockingRuntimeEvent(event: SessionEvent): boolean { const shellProcessStatus = shellProcessStatusFromArgs(event.args); if (shellProcessStatus) { @@ -106,10 +149,11 @@ export function isTurnBlockingRuntimeEvent(event: SessionEvent): boolean { export function isComposerStopBlockingEvent(event: SessionEvent): boolean { if (!isTurnBlockingRuntimeEvent(event)) return false; - const shellProcessStatus = shellProcessStatusFromArgs(event.args); - if (shellProcessStatus) { - return TURN_BLOCKING_SHELL_PROCESS_STATUSES.has(shellProcessStatus); - } + // isTurnBlockingRuntimeEvent already validated the shell process status; if + // shellProcessStatus is present the event is a turn-blocking shell — it is + // always stop-blocking. Only fall through to the tool_call check for events + // whose blocking status comes from displayStatus / result.status instead. + if (shellProcessStatusFromArgs(event.args)) return true; return ( event.actionType === "tool_call" || event.displayVariant === "tool_call" diff --git a/src/engines/SessionCore/core/store/EventStoreProxy.ts b/src/engines/SessionCore/core/store/EventStoreProxy.ts index 0eaa226a7..622e37bb4 100644 --- a/src/engines/SessionCore/core/store/EventStoreProxy.ts +++ b/src/engines/SessionCore/core/store/EventStoreProxy.ts @@ -8,6 +8,11 @@ * 2. Listens to `es:changed` Tauri events for read notifications * 3. Routes snapshots by `sessionId` so per-session subscribers (e.g. * subagent nested blocks) only receive updates for their session. + * 4. Applies delta envelopes to the per-session normalized cache in arrival + * order (lossless), but coalesces the expensive materialize + notify to + * at most once per animation frame per session. Synchronous read paths + * and lifecycle transitions force-flush, so only pure-render consumers + * can observe the ≤1-frame staleness window. * * Components continue using Jotai atoms (eventsAtom, chatEventsAtom, etc.) * which are fed from the derived snapshot pushed by Rust. @@ -30,7 +35,16 @@ import type { } from "./EventStoreProxyTypes"; import { inferSessionId, isRealUserEvent } from "./eventStoreEvents"; import { estimateObjectBytes } from "./memoryEstimation"; -import { rememberSnapshot, resolveSnapshotPayload } from "./snapshotCache"; +import { + applyDeltaEnvelope, + flushPendingDelta, + rememberSnapshot, +} from "./snapshotCache"; +import { + type PendingDeltaState, + isSnapshotDelta, + isStreamingSnapshot, +} from "./snapshotMaterialization"; export type { DerivedSnapshot, @@ -43,9 +57,43 @@ export type { } from "./EventStoreProxyTypes"; export { isStreamingSnapshot } from "./snapshotMaterialization"; -const SNAPSHOT_CACHE_MAX = 20; +const SNAPSHOT_CACHE_MAX = 5; + +// Total cached events across all retained snapshots. The count cap alone let +// "20 sessions" quietly mean hundreds of MB once transcripts got long; this +// bounds the cache by its dominant cost driver instead. Switch-back to an +// evicted session refetches its snapshot from Rust (one IPC round trip). +const SNAPSHOT_CACHE_EVENT_BUDGET = 15_000; +/** + * Grace window before a switched-away session's snapshot is released. + * Rapid ping-ponging between sessions keeps the instant JS-cache prime and + * the delta path; anything not revisited within the window is freed. + */ +const SNAPSHOT_RELEASE_GRACE_MS = 3 * 60 * 1000; const log = createLogger("EventStoreProxy"); +/** + * Schedule a callback for the next animation frame; falls back to a 16ms + * timeout in non-DOM environments (tests). Returns a canceller. + */ +function scheduleFrameCallback(callback: () => void): () => void { + if (typeof requestAnimationFrame === "function") { + const handle = requestAnimationFrame(() => callback()); + return () => cancelAnimationFrame(handle); + } + const timer = setTimeout(callback, 16); + return () => clearTimeout(timer); +} + +interface PendingSessionFlush { + /** + * Un-materialized delta state already applied to the normalized cache; + * null when only the notify for an already-remembered snapshot is pending. + */ + delta: PendingDeltaState | null; + cancelSchedule: () => void; +} + class EventStoreProxyImpl { private _globalListeners = new Set(); private _sessionListeners = new Map>(); @@ -61,6 +109,21 @@ class EventStoreProxyImpl { * and apply out of order (older snapshot remembered after a newer one). */ private _envelopeChains = new Map>(); + /** Pending deferred snapshot releases, keyed by sessionId. */ + private _snapshotReleaseTimers = new Map< + string, + ReturnType + >(); + /** + * Per-session coalescing state: cache updates are applied per envelope + * (ordered, lossless), while materialize + notify runs at most once per + * animation frame per session. Pure-render consumers may therefore see + * state up to one frame stale; every synchronous read path + * (getLatestSessionSnapshot, latestSnapshot, getMemoryStats) and lifecycle + * transition (switch / release / evict, streaming end) force-flushes + * first, so no correctness-sensitive path observes the window. + */ + private _pendingFlushes = new Map(); /** * Initialize the Tauri event listener. Call once at app startup. @@ -115,37 +178,116 @@ class EventStoreProxyImpl { envelope: SnapshotEnvelope ): Promise { const { sessionId, ...payload } = envelope; - const snapshot = await this._resolveSnapshotPayload( - sessionId, - payload as SnapshotPayload - ); - const rememberedSnapshot = this._rememberSnapshot(sessionId, snapshot); - this._notifyListeners(rememberedSnapshot, sessionId); - } + const snapshotPayload = payload as SnapshotPayload; - private async _resolveSnapshotPayload( - sessionId: string, - payload: SnapshotPayload - ): Promise { - return resolveSnapshotPayload( - sessionId, - payload, - this._latestSnapshots, - this._normalizedSnapshots, - (snapshotSessionId) => this.getSnapshot(snapshotSessionId) - ); + if (isSnapshotDelta(snapshotPayload)) { + const pendingDelta = applyDeltaEnvelope( + sessionId, + snapshotPayload, + this._latestSnapshots, + this._normalizedSnapshots, + this._pendingFlushes.get(sessionId)?.delta ?? null + ); + if (pendingDelta) { + this._schedulePendingFlush(sessionId, pendingDelta); + return; + } + // Delta base miss (no cache or version gap): fetch + remember the full + // snapshot, then notify on the coalesced schedule like any envelope. + await this.getSnapshot(sessionId); + this._schedulePendingFlush(sessionId, null); + return; + } + + this._rememberSnapshot(sessionId, snapshotPayload); + this._schedulePendingFlush(sessionId, null); } private _rememberSnapshot(sessionId: string, snapshot: Snapshot): Snapshot { + const pending = this._pendingFlushes.get(sessionId); + if (pending?.delta) { + if (snapshot.version < pending.delta.version) { + // Un-materialized deltas are already newer than this slow-resolving + // full snapshot; surface them instead of clobbering the cache. + return this._flushPendingSnapshot(sessionId) ?? snapshot; + } + // The full snapshot supersedes everything accumulated so far. + pending.delta = null; + } return rememberSnapshot( sessionId, snapshot, this._latestSnapshots, this._normalizedSnapshots, - SNAPSHOT_CACHE_MAX + SNAPSHOT_CACHE_MAX, + SNAPSHOT_CACHE_EVENT_BUDGET, + (evicted) => this._dropPendingFlush(evicted) ); } + /** + * Schedule the per-frame materialize + notify for a session. Passing a + * delta records it as the (single, mutated-in-place) accumulator; passing + * null keeps an existing accumulator and merely ensures a notify fires. + */ + private _schedulePendingFlush( + sessionId: string, + delta: PendingDeltaState | null + ): void { + const existing = this._pendingFlushes.get(sessionId); + if (existing) { + if (delta) existing.delta = delta; + return; + } + this._pendingFlushes.set(sessionId, { + delta, + cancelSchedule: scheduleFrameCallback(() => { + this._flushPendingSnapshot(sessionId); + }), + }); + } + + /** + * Force-materialize and notify a session's pending state now. Returns the + * notified snapshot, or null when nothing was pending or the session's + * cache is gone (released / evicted before the flush). + */ + private _flushPendingSnapshot(sessionId: string): Snapshot | null { + const pending = this._pendingFlushes.get(sessionId); + if (!pending) return null; + pending.cancelSchedule(); + this._pendingFlushes.delete(sessionId); + const snapshot = pending.delta + ? flushPendingDelta( + sessionId, + pending.delta, + this._latestSnapshots, + this._normalizedSnapshots, + SNAPSHOT_CACHE_MAX, + SNAPSHOT_CACHE_EVENT_BUDGET, + (evicted) => this._dropPendingFlush(evicted) + ) + : (this._latestSnapshots.get(sessionId) ?? null); + if (snapshot) { + this._notifyListeners(snapshot, sessionId); + } + return snapshot; + } + + private _flushAllPendingSnapshots(): void { + for (const sessionId of [...this._pendingFlushes.keys()]) { + this._flushPendingSnapshot(sessionId); + } + } + + /** Drop pending state without materializing (session evicted from LRU). */ + private _dropPendingFlush(sessionId: string): void { + const pending = this._pendingFlushes.get(sessionId); + if (!pending) return; + pending.cancelSchedule(); + this._pendingFlushes.delete(sessionId); + } + /** * Detach only the Tauri `es:changed` listener. * @@ -173,6 +315,14 @@ class EventStoreProxyImpl { this._sessionListeners.clear(); this._latestSnapshots.clear(); this._normalizedSnapshots.clear(); + for (const pending of this._pendingFlushes.values()) { + pending.cancelSchedule(); + } + this._pendingFlushes.clear(); + for (const timer of this._snapshotReleaseTimers.values()) { + clearTimeout(timer); + } + this._snapshotReleaseTimers.clear(); } // ========================================================================= @@ -213,6 +363,8 @@ class EventStoreProxyImpl { /** Get the latest snapshot for a specific session (may be null). */ getLatestSessionSnapshot(sessionId: string): Snapshot | null { + // Synchronous readers must never observe the one-frame coalescing window. + this._flushPendingSnapshot(sessionId); return this._latestSnapshots.get(sessionId) ?? null; } @@ -223,12 +375,73 @@ class EventStoreProxyImpl { * cache stays in sync and doesn't hold large event arrays for idle sessions. */ evictSessionCache(sessionId: string): void { + // Surface the final coalesced state to listeners before they are dropped. + this._flushPendingSnapshot(sessionId); this._latestSnapshots.delete(sessionId); this._normalizedSnapshots.delete(sessionId); this._sessionListeners.delete(sessionId); } + /** + * Drop only the cached snapshot data (materialized + normalized) for a + * session, keeping `_sessionListeners` intact so still-mounted consumers + * keep receiving future pushes — the next envelope re-primes the cache + * (via a full snapshot fetch if it arrives as a delta). + * + * Use on session switch-away and when Rust idle-evicts a session: the full + * event arrays are the dominant per-session JS-heap cost, and without this + * every visited session stays resident until SNAPSHOT_CACHE_MAX pushes it + * out. + */ + releaseSessionSnapshot(sessionId: string): void { + this.cancelScheduledSnapshotRelease(sessionId); + // Deliver the final coalesced state before dropping it — a delta applied + // this frame must reach subscribers even though its cache is released. + this._flushPendingSnapshot(sessionId); + this._latestSnapshots.delete(sessionId); + this._normalizedSnapshots.delete(sessionId); + } + + /** + * `releaseSessionSnapshot`, but skipped while the session's latest snapshot + * is still streaming — an active background session keeps pushing + * envelopes, so evicting it would only force a full-snapshot refetch on its + * next delta. + */ + releaseSessionSnapshotIfIdle(sessionId: string): void { + this._flushPendingSnapshot(sessionId); + const cached = this._latestSnapshots.get(sessionId); + if (cached && isStreamingSnapshot(cached)) return; + this.releaseSessionSnapshot(sessionId); + } + + /** + * Deferred `releaseSessionSnapshotIfIdle` for a session the UI just + * switched away from. The grace window keeps rapid switch-backs warm + * (instant cache prime, delta application stays valid); becoming active + * again cancels the release via `cancelScheduledSnapshotRelease`. + * Streaming is re-checked when the timer fires. + */ + scheduleSessionSnapshotRelease(sessionId: string): void { + this.cancelScheduledSnapshotRelease(sessionId); + const timer = setTimeout(() => { + this._snapshotReleaseTimers.delete(sessionId); + this.releaseSessionSnapshotIfIdle(sessionId); + }, SNAPSHOT_RELEASE_GRACE_MS); + this._snapshotReleaseTimers.set(sessionId, timer); + } + + /** Cancel a pending deferred release (the session is active again). */ + cancelScheduledSnapshotRelease(sessionId: string): void { + const timer = this._snapshotReleaseTimers.get(sessionId); + if (timer === undefined) return; + clearTimeout(timer); + this._snapshotReleaseTimers.delete(sessionId); + } + getMemoryStats(): EventStoreMemoryStats { + // Materialize pending state first so the reported sizes are current. + this._flushAllPendingSnapshots(); let cachedEvents = 0; let bytes = 0; for (const snapshot of this._latestSnapshots.values()) { @@ -248,6 +461,7 @@ class EventStoreProxyImpl { /** Get the latest snapshot (any session — last received). */ get latestSnapshot(): Snapshot | null { + this._flushAllPendingSnapshots(); if (this._latestSnapshots.size === 0) return null; let latest: Snapshot | null = null; for (const snap of this._latestSnapshots.values()) { @@ -348,6 +562,11 @@ class EventStoreProxyImpl { /** Set streaming mode on/off. */ async setStreaming(streaming: boolean, sessionId?: string): Promise { + // Stream completion must surface the final coalesced state immediately — + // completion handlers read snapshot-derived state right after this call. + if (!streaming && sessionId) { + this._flushPendingSnapshot(sessionId); + } await rpc.sessionCore.eventStore.setStreaming({ streaming, sessionId: sessionId ?? null, @@ -378,6 +597,12 @@ class EventStoreProxyImpl { /** Switch the active session. Returns true if cache hit. */ async switchSession(sessionId: string): Promise { + // Becoming active again rescues the snapshot from a pending deferred + // release scheduled when the user previously switched away. + this.cancelScheduledSnapshotRelease(sessionId); + // The bridge primes the incoming session from the JS cache — it must not + // read state that is stale by a frame of un-materialized deltas. + this._flushPendingSnapshot(sessionId); return rpc.sessionCore.eventStore.switchSession({ sessionId }); } @@ -428,6 +653,23 @@ class EventStoreProxyImpl { }) as Promise; } + /** + * Read the FULL persisted event history from the SQLite cache, bypassing + * the (possibly turn-windowed / LRU-evicted) in-memory store entirely. + * + * The in-memory store is a windowed view: `getEvents` on a non-resident + * session returns `[]`, and a session hydrated via `loadInitialTurnWindow` + * holds placeholders instead of full turn bodies. Consumers that need the + * durable truth (e.g. the collaboration segments push, design §7.3 step 1) + * must read here. Rust persists events on ingestion, so this lags a live + * stream by at most one write batch. + */ + async getPersistedEvents(sessionId: string): Promise { + return rpc.sessionCore.cache.loadEvents({ + sessionId, + }) as Promise; + } + // ========================================================================= // SQLite Bridge // ========================================================================= diff --git a/src/engines/SessionCore/core/store/__tests__/EventStoreProxy.test.ts b/src/engines/SessionCore/core/store/__tests__/EventStoreProxy.test.ts index 2bd67df0a..d5a408b9e 100644 --- a/src/engines/SessionCore/core/store/__tests__/EventStoreProxy.test.ts +++ b/src/engines/SessionCore/core/store/__tests__/EventStoreProxy.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import type { SessionEvent } from "../../types"; import { eventStoreProxy } from "../EventStoreProxy"; +import type { DerivedSnapshot, StreamingSnapshot } from "../EventStoreProxy"; const { rpcMock, warnMock } = vi.hoisted(() => ({ rpcMock: { @@ -13,6 +14,8 @@ const { rpcMock, warnMock } = vi.hoisted(() => ({ set: vi.fn().mockResolvedValue(undefined), upsert: vi.fn().mockResolvedValue(undefined), saveToCache: vi.fn().mockResolvedValue(1), + getSnapshot: vi.fn(), + switchSession: vi.fn().mockResolvedValue(true), }, }, }, @@ -96,6 +99,131 @@ describe("EventStoreProxy session targeting", () => { }); }); +describe("EventStoreProxy snapshot release", () => { + it("releaseSessionSnapshot drops the cached snapshot", async () => { + rpcMock.sessionCore.eventStore.getSnapshot.mockResolvedValueOnce( + makeDerivedSnapshot("session-release", 3) + ); + await eventStoreProxy.getSnapshot("session-release"); + expect( + eventStoreProxy.getLatestSessionSnapshot("session-release") + ).not.toBeNull(); + + eventStoreProxy.releaseSessionSnapshot("session-release"); + + expect( + eventStoreProxy.getLatestSessionSnapshot("session-release") + ).toBeNull(); + }); + + it("releaseSessionSnapshotIfIdle keeps a streaming snapshot", async () => { + rpcMock.sessionCore.eventStore.getSnapshot.mockResolvedValueOnce( + makeStreamingSnapshot("session-live", 4) + ); + await eventStoreProxy.getSnapshot("session-live"); + + eventStoreProxy.releaseSessionSnapshotIfIdle("session-live"); + + expect( + eventStoreProxy.getLatestSessionSnapshot("session-live") + ).not.toBeNull(); + }); + + it("releaseSessionSnapshotIfIdle drops an idle snapshot", async () => { + rpcMock.sessionCore.eventStore.getSnapshot.mockResolvedValueOnce( + makeDerivedSnapshot("session-idle", 5) + ); + await eventStoreProxy.getSnapshot("session-idle"); + + eventStoreProxy.releaseSessionSnapshotIfIdle("session-idle"); + + expect(eventStoreProxy.getLatestSessionSnapshot("session-idle")).toBeNull(); + }); +}); + +describe("EventStoreProxy deferred snapshot release", () => { + const GRACE_MS = 3 * 60 * 1000; + + it("releases the snapshot only after the grace window", async () => { + vi.useFakeTimers(); + try { + rpcMock.sessionCore.eventStore.getSnapshot.mockResolvedValueOnce( + makeDerivedSnapshot("session-deferred", 6) + ); + await eventStoreProxy.getSnapshot("session-deferred"); + + eventStoreProxy.scheduleSessionSnapshotRelease("session-deferred"); + vi.advanceTimersByTime(GRACE_MS - 1); + expect( + eventStoreProxy.getLatestSessionSnapshot("session-deferred") + ).not.toBeNull(); + + vi.advanceTimersByTime(2); + expect( + eventStoreProxy.getLatestSessionSnapshot("session-deferred") + ).toBeNull(); + } finally { + vi.useRealTimers(); + } + }); + + it("switching back within the grace window cancels the release", async () => { + vi.useFakeTimers(); + try { + rpcMock.sessionCore.eventStore.getSnapshot.mockResolvedValueOnce( + makeDerivedSnapshot("session-returned", 7) + ); + await eventStoreProxy.getSnapshot("session-returned"); + + eventStoreProxy.scheduleSessionSnapshotRelease("session-returned"); + vi.advanceTimersByTime(GRACE_MS / 2); + await eventStoreProxy.switchSession("session-returned"); + vi.advanceTimersByTime(GRACE_MS * 4); + + expect( + eventStoreProxy.getLatestSessionSnapshot("session-returned") + ).not.toBeNull(); + } finally { + vi.useRealTimers(); + } + }); +}); + +function makeDerivedSnapshot( + sessionId: string, + version: number +): DerivedSnapshot { + const events = [makeEvent(`${sessionId}-event-1`, sessionId)]; + return { + version, + eventCount: events.length, + events, + chatEvents: events, + messagesEvents: events, + sortedSimulatorEvents: [], + lastEvent: events[events.length - 1] ?? null, + eventIndex: {}, + chatEventCount: events.length, + hasRunningEvent: false, + }; +} + +function makeStreamingSnapshot( + sessionId: string, + version: number +): StreamingSnapshot { + const events = [makeEvent(`${sessionId}-event-1`, sessionId)]; + return { + version, + eventCount: events.length, + chatEvents: events, + sortedSimulatorEvents: [], + lastEvent: events[events.length - 1] ?? null, + streaming: true, + hasRunningEvent: true, + }; +} + function makeEvent(id: string, sessionId: string): SessionEvent { return { id, diff --git a/src/engines/SessionCore/core/store/__tests__/snapshotCoalescing.test.ts b/src/engines/SessionCore/core/store/__tests__/snapshotCoalescing.test.ts new file mode 100644 index 000000000..1f43d62ad --- /dev/null +++ b/src/engines/SessionCore/core/store/__tests__/snapshotCoalescing.test.ts @@ -0,0 +1,346 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { SessionEvent } from "../../types"; +import { eventStoreProxy } from "../EventStoreProxy"; +import type { + DerivedSnapshot, + SnapshotDelta, + SnapshotEnvelope, +} from "../EventStoreProxy"; + +type DerivedEnvelope = DerivedSnapshot & { sessionId: string }; +type DeltaEnvelope = SnapshotDelta & { sessionId: string }; + +const { rpcMock, listenState } = vi.hoisted(() => ({ + rpcMock: { + sessionCore: { + eventStore: { + getSnapshot: vi.fn(), + switchSession: vi.fn().mockResolvedValue(true), + setStreaming: vi.fn().mockResolvedValue(undefined), + }, + }, + }, + listenState: { + handler: null as ((event: { payload: unknown }) => void) | null, + }, +})); + +vi.mock("@src/api/tauri/rpc", () => ({ + rpc: rpcMock, +})); + +vi.mock("@tauri-apps/api/event", () => ({ + listen: (_name: string, handler: (event: { payload: unknown }) => void) => { + listenState.handler = handler; + return Promise.resolve(() => { + listenState.handler = null; + }); + }, +})); + +/** Push an envelope through the proxy's Tauri listener and drain the + * per-session envelope chain (microtasks only — no frame flush). */ +async function deliver(envelope: SnapshotEnvelope): Promise { + listenState.handler!({ payload: envelope }); + await vi.advanceTimersByTimeAsync(0); +} + +/** Fire the coalesced per-frame flush (setTimeout(16) fallback in node). */ +async function advanceFrame(): Promise { + await vi.advanceTimersByTimeAsync(16); +} + +describe("EventStoreProxy snapshot coalescing", () => { + beforeEach(async () => { + vi.useFakeTimers(); + await eventStoreProxy.init(); + }); + + afterEach(() => { + eventStoreProxy.destroy(); + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + it("reuses the previous eventIndex, unchanged arrays and preview objects on a single-event delta upsert", async () => { + const sessionId = "session-reuse"; + const e1 = makeEvent("e1", sessionId); + const e2 = makeEvent("e2", sessionId); + const notified: DerivedSnapshot[] = []; + const unsubscribe = eventStoreProxy.subscribe((snapshot, sid) => { + if (sid === sessionId) notified.push(snapshot as DerivedSnapshot); + }); + + await deliver(makeDerivedEnvelope(sessionId, 1, [e1, e2])); + await advanceFrame(); + expect(notified).toHaveLength(1); + const first = notified[0]; + + const e2Updated: SessionEvent = { ...e2, displayStatus: "failed" }; + await deliver( + makeDeltaEnvelope(sessionId, 1, 2, [e2Updated], { + eventIds: ["e1", "e2"], + chatEventIds: ["e1", "e2"], + messagesEventIds: ["e1"], + sortedSimulatorEventIds: ["e1", "e2"], + lastEventId: "e2", + }) + ); + await advanceFrame(); + expect(notified).toHaveLength(2); + const second = notified[1]; + + // Ordering unchanged → the index object is reused, not rebuilt. + expect(second.eventIndex).toBe(first.eventIndex); + // Pointer-copied array: only the changed slot was swapped. + expect(second.events).not.toBe(first.events); + expect(second.events[0]).toBe(first.events[0]); + expect(second.events[1]).toBe(e2Updated); + // No referenced event changed → the whole array is reused. + expect(second.messagesEvents).toBe(first.messagesEvents); + // Unchanged events keep their preview object identity. + expect(second.eventPreviewById?.e1).toBe(first.eventPreviewById?.e1); + expect(second.eventPreviewById?.e2).not.toBe(first.eventPreviewById?.e2); + // Copy-on-write Records: value-identical ones keep their identity. + expect(second.createdAtById).toBe(first.createdAtById); + expect(second.displayStatusById).not.toBe(first.displayStatusById); + expect(second.displayStatusById?.e2).toBe("failed"); + + unsubscribe(); + }); + + it("coalesces N same-frame envelopes into exactly one notify carrying the final state", async () => { + const sessionId = "session-coalesce"; + const e1 = makeEvent("e1", sessionId); + const listener = vi.fn(); + const unsubscribe = eventStoreProxy.subscribe(listener); + + await deliver(makeDerivedEnvelope(sessionId, 1, [e1])); + await advanceFrame(); + expect(listener).toHaveBeenCalledTimes(1); + listener.mockClear(); + + const ids = { + eventIds: ["e1"], + chatEventIds: ["e1"], + messagesEventIds: ["e1"], + sortedSimulatorEventIds: ["e1"], + lastEventId: "e1", + }; + await deliver( + makeDeltaEnvelope(sessionId, 1, 2, [{ ...e1, displayText: "a" }], ids) + ); + await deliver( + makeDeltaEnvelope(sessionId, 2, 3, [{ ...e1, displayText: "ab" }], ids) + ); + await deliver( + makeDeltaEnvelope(sessionId, 3, 4, [{ ...e1, displayText: "abc" }], ids) + ); + expect(listener).not.toHaveBeenCalled(); + + await advanceFrame(); + expect(listener).toHaveBeenCalledTimes(1); + const [snapshot] = listener.mock.calls[0] as [DerivedSnapshot, string]; + expect(snapshot.version).toBe(4); + expect(snapshot.events[0].displayText).toBe("abc"); + + unsubscribe(); + }); + + it("force-flushes pending deltas on releaseSessionSnapshot", async () => { + const sessionId = "session-release-flush"; + const e1 = makeEvent("e1", sessionId); + const listener = vi.fn(); + const unsubscribe = eventStoreProxy.subscribe(listener); + + await deliver(makeDerivedEnvelope(sessionId, 1, [e1])); + await advanceFrame(); + listener.mockClear(); + + await deliver( + makeDeltaEnvelope(sessionId, 1, 2, [{ ...e1, displayText: "final" }], { + eventIds: ["e1"], + chatEventIds: ["e1"], + messagesEventIds: ["e1"], + sortedSimulatorEventIds: ["e1"], + lastEventId: "e1", + }) + ); + expect(listener).not.toHaveBeenCalled(); + + eventStoreProxy.releaseSessionSnapshot(sessionId); + // The pending delta reached subscribers synchronously, before the drop. + expect(listener).toHaveBeenCalledTimes(1); + const [snapshot] = listener.mock.calls[0] as [DerivedSnapshot, string]; + expect(snapshot.version).toBe(2); + expect(snapshot.events[0].displayText).toBe("final"); + expect(eventStoreProxy.getLatestSessionSnapshot(sessionId)).toBeNull(); + + // The cancelled schedule must not fire a second notify. + await advanceFrame(); + expect(listener).toHaveBeenCalledTimes(1); + + unsubscribe(); + }); + + it("getLatestSessionSnapshot never observes the one-frame staleness window", async () => { + const sessionId = "session-sync-read"; + const e1 = makeEvent("e1", sessionId); + await deliver(makeDerivedEnvelope(sessionId, 1, [e1])); + await advanceFrame(); + + await deliver( + makeDeltaEnvelope(sessionId, 1, 2, [{ ...e1, displayText: "fresh" }], { + eventIds: ["e1"], + chatEventIds: ["e1"], + messagesEventIds: ["e1"], + sortedSimulatorEventIds: ["e1"], + lastEventId: "e1", + }) + ); + + const snapshot = eventStoreProxy.getLatestSessionSnapshot( + sessionId + ) as DerivedSnapshot; + expect(snapshot.version).toBe(2); + expect(snapshot.events[0].displayText).toBe("fresh"); + }); + + it("evicts oldest sessions when cached events exceed the budget", async () => { + const bigSession = (sessionId: string) => + makeDerivedEnvelope( + sessionId, + 1, + Array.from({ length: 6_000 }, (_, i) => + makeEvent(`${sessionId}-e${i}`, sessionId) + ) + ); + + await deliver(bigSession("session-a")); + await deliver(bigSession("session-b")); + await advanceFrame(); + // 12k events fits the 15k budget: both retained. + expect(eventStoreProxy.getMemoryStats().cachedSessions).toBe(2); + + await deliver(bigSession("session-c")); + await advanceFrame(); + // 18k exceeds the budget: oldest (session-a) evicted, newest kept. + const stats = eventStoreProxy.getMemoryStats(); + expect(stats.cachedSessions).toBe(2); + expect(stats.cachedEvents).toBeLessThanOrEqual(15_000); + expect( + eventStoreProxy.getLatestSessionSnapshot("session-c") + ).not.toBeNull(); + expect(eventStoreProxy.getLatestSessionSnapshot("session-a")).toBeNull(); + }); + + it("falls back to a full snapshot fetch on a delta base-version miss", async () => { + const sessionId = "session-base-miss"; + const e1 = makeEvent("e1", sessionId); + const listener = vi.fn(); + const unsubscribe = eventStoreProxy.subscribe(listener); + + rpcMock.sessionCore.eventStore.getSnapshot.mockResolvedValueOnce( + makeDerivedEnvelopePayload(7, [e1]) + ); + await deliver( + makeDeltaEnvelope(sessionId, 6, 7, [e1], { + eventIds: ["e1"], + chatEventIds: ["e1"], + messagesEventIds: ["e1"], + sortedSimulatorEventIds: ["e1"], + lastEventId: "e1", + }) + ); + expect(rpcMock.sessionCore.eventStore.getSnapshot).toHaveBeenCalledWith({ + sessionId, + }); + + await advanceFrame(); + expect(listener).toHaveBeenCalledTimes(1); + const [snapshot] = listener.mock.calls[0] as [DerivedSnapshot, string]; + expect(snapshot.version).toBe(7); + + unsubscribe(); + }); +}); + +function makeDerivedEnvelopePayload( + version: number, + events: SessionEvent[] +): DerivedSnapshot { + return { + version, + eventCount: events.length, + events, + chatEvents: events, + messagesEvents: events.slice(0, 1), + sortedSimulatorEvents: events, + lastEvent: events[events.length - 1] ?? null, + eventIndex: {}, + chatEventCount: events.length, + hasRunningEvent: false, + }; +} + +function makeDerivedEnvelope( + sessionId: string, + version: number, + events: SessionEvent[] +): DerivedEnvelope { + return { + sessionId, + ...makeDerivedEnvelopePayload(version, events), + }; +} + +function makeDeltaEnvelope( + sessionId: string, + baseVersion: number, + version: number, + upserts: SessionEvent[], + ids: { + eventIds: string[]; + chatEventIds: string[]; + messagesEventIds: string[]; + sortedSimulatorEventIds: string[]; + lastEventId: string | null; + } +): DeltaEnvelope { + return { + sessionId, + snapshotDelta: true, + version, + baseVersion, + eventCount: ids.eventIds.length, + upserts, + removedIds: [], + eventIds: ids.eventIds, + chatEventIds: ids.chatEventIds, + messagesEventIds: ids.messagesEventIds, + sortedSimulatorEventIds: ids.sortedSimulatorEventIds, + lastEventId: ids.lastEventId, + chatEventCount: ids.chatEventIds.length, + hasRunningEvent: true, + }; +} + +function makeEvent(id: string, sessionId: string): SessionEvent { + return { + id, + chunk_id: id, + sessionId, + createdAt: "2026-07-16T00:00:00.000Z", + functionName: "assistant_message", + uiCanonical: "message", + actionType: "assistant", + args: {}, + result: { observation: id }, + source: "assistant", + displayText: id, + displayStatus: "completed", + displayVariant: "message", + activityStatus: "agent", + }; +} diff --git a/src/engines/SessionCore/core/store/snapshotCache.ts b/src/engines/SessionCore/core/store/snapshotCache.ts index 4834b620d..5f8a79e05 100644 --- a/src/engines/SessionCore/core/store/snapshotCache.ts +++ b/src/engines/SessionCore/core/store/snapshotCache.ts @@ -2,45 +2,128 @@ import type { DerivedSnapshot, NormalizedSnapshotCache, Snapshot, - SnapshotPayload, + SnapshotDelta, } from "./EventStoreProxyTypes"; import { + type PendingDeltaState, + applyDeltaToCache, buildNormalizedCache, - isSessionEvent, - isSnapshotDelta, isStreamingSnapshot, materializeFullSnapshot, - materializeSnapshot, + materializePendingDelta, materializeStreamingSnapshot, } from "./snapshotMaterialization"; -export async function resolveSnapshotPayload( +/** + * Store a materialized snapshot with most-recently-written LRU ordering. + * Does NOT touch the session's normalized cache — callers own cache + * consistency. Returns the sessionId evicted to honor `maxSnapshots` (if + * any) so the owner can drop per-session bookkeeping such as pending + * flushes. + */ +/** + * Rough retained-size proxy for one cached snapshot: its event count. The + * per-event object graph dominates the cache's footprint, so budgeting by + * events keeps a few heavy transcripts OR many light ones without letting + * "count-bounded" turn into hundreds of MB. + */ +function snapshotEventWeight(snapshot: Snapshot): number { + if ("events" in snapshot) { + return (snapshot as DerivedSnapshot).events.length; + } + return snapshot.chatEvents?.length ?? 0; +} + +function storeSnapshot( sessionId: string, - payload: SnapshotPayload, + snapshot: Snapshot, latestSnapshots: Map, normalizedSnapshots: Map, - fetchSnapshot: (sessionId: string) => Promise -): Promise { - if (!isSnapshotDelta(payload)) return payload; + maxSnapshots: number, + eventBudget: number +): string[] { + latestSnapshots.delete(sessionId); + latestSnapshots.set(sessionId, snapshot); - const previous = latestSnapshots.get(sessionId); - const cache = normalizedSnapshots.get(sessionId); - if (!previous || !cache || previous.version !== payload.baseVersion) { - return fetchSnapshot(sessionId); + const evicted: string[] = []; + let totalWeight = 0; + for (const cached of latestSnapshots.values()) { + totalWeight += snapshotEventWeight(cached); } - - for (const removedId of payload.removedIds) { - cache.eventsById.delete(removedId); + // Evict oldest-first until both bounds hold. The just-stored session is + // the newest entry and is never evicted, even when it alone exceeds the + // budget (a single huge transcript must stay usable). + while ( + latestSnapshots.size > 1 && + (latestSnapshots.size > maxSnapshots || totalWeight > eventBudget) + ) { + const oldest = latestSnapshots.keys().next().value; + if (oldest === undefined || oldest === sessionId) break; + const oldestSnapshot = latestSnapshots.get(oldest); + totalWeight -= oldestSnapshot ? snapshotEventWeight(oldestSnapshot) : 0; + latestSnapshots.delete(oldest); + normalizedSnapshots.delete(oldest); + evicted.push(oldest); } - for (const event of payload.upserts) { - if (!isSessionEvent(event)) continue; - cache.eventsById.set(event.id, event); + return evicted; +} + +/** + * Apply a delta envelope to the session's normalized cache WITHOUT + * materializing a snapshot. Returns the (new or updated) pending state on + * success; null when the delta cannot be applied — no cache, or the delta's + * base version does not match the newest known state (the stored snapshot, + * or pending deltas already applied on top of it) — in which case the + * caller must fall back to a full snapshot fetch. + */ +export function applyDeltaEnvelope( + sessionId: string, + delta: SnapshotDelta, + latestSnapshots: Map, + normalizedSnapshots: Map, + pending: PendingDeltaState | null +): PendingDeltaState | null { + const cache = normalizedSnapshots.get(sessionId); + if (!cache) return null; + const baseVersion = + pending?.version ?? latestSnapshots.get(sessionId)?.version; + if (baseVersion !== delta.baseVersion) return null; + return applyDeltaToCache(delta, cache, pending); +} + +/** + * Materialize accumulated delta state and store the result. Returns the + * stored snapshot, or null when the session's cache is gone (released or + * evicted between accumulation and flush). A stored snapshot at least as + * new as the pending state wins — the pending deltas were superseded by a + * full snapshot remembered in the meantime. + */ +export function flushPendingDelta( + sessionId: string, + pending: PendingDeltaState, + latestSnapshots: Map, + normalizedSnapshots: Map, + maxSnapshots: number, + eventBudget: number, + onEvicted?: (evictedSessionId: string) => void +): Snapshot | null { + const cache = normalizedSnapshots.get(sessionId); + if (!cache) return null; + const previous = latestSnapshots.get(sessionId); + if (previous && previous.version >= pending.version) { + return previous; } - cache.eventIds = payload.eventIds; - cache.chatEventIds = payload.chatEventIds; - cache.messagesEventIds = payload.messagesEventIds; - cache.sortedSimulatorEventIds = payload.sortedSimulatorEventIds; - return materializeSnapshot(payload, cache); + const snapshot = materializePendingDelta(pending, cache, previous); + const evicted = storeSnapshot( + sessionId, + snapshot, + latestSnapshots, + normalizedSnapshots, + maxSnapshots, + eventBudget + ); + for (const evictedSessionId of evicted) onEvicted?.(evictedSessionId); + return snapshot; } export function rememberSnapshot( @@ -48,7 +131,9 @@ export function rememberSnapshot( snapshot: Snapshot, latestSnapshots: Map, normalizedSnapshots: Map, - maxSnapshots: number + maxSnapshots: number, + eventBudget: number, + onEvicted?: (evictedSessionId: string) => void ): Snapshot { // Reject version regressions: a late-arriving older snapshot (e.g. a slow // getSnapshot() resolving after a newer push was already remembered) must @@ -70,16 +155,15 @@ export function rememberSnapshot( normalizedSnapshots.set(sessionId, normalized); } - latestSnapshots.delete(sessionId); - latestSnapshots.set(sessionId, snapshotToStore); - - if (latestSnapshots.size > maxSnapshots) { - const oldest = latestSnapshots.keys().next().value; - if (oldest !== undefined) { - latestSnapshots.delete(oldest); - normalizedSnapshots.delete(oldest); - } - } + const evicted = storeSnapshot( + sessionId, + snapshotToStore, + latestSnapshots, + normalizedSnapshots, + maxSnapshots, + eventBudget + ); + for (const evictedSessionId of evicted) onEvicted?.(evictedSessionId); return snapshotToStore; } diff --git a/src/engines/SessionCore/core/store/snapshotMaterialization.ts b/src/engines/SessionCore/core/store/snapshotMaterialization.ts index 28ad4e828..7931c5ae4 100644 --- a/src/engines/SessionCore/core/store/snapshotMaterialization.ts +++ b/src/engines/SessionCore/core/store/snapshotMaterialization.ts @@ -5,6 +5,7 @@ import type { } from "../types"; import type { DerivedSnapshot, + LatestCanvasPreview, NormalizedSnapshotCache, Snapshot, SnapshotDelta, @@ -78,6 +79,24 @@ function buildSimulatorEventPreview( }; } +/** + * Preview objects are pure projections of their event, so they are cached by + * event object identity: events untouched by a delta keep the same object in + * `eventsById` and therefore reuse their preview across materializations. + */ +const simulatorPreviewCache = new WeakMap< + SessionEvent, + SimulatorEventPreview +>(); + +function previewForEvent(event: SessionEvent): SimulatorEventPreview { + const cached = simulatorPreviewCache.get(event); + if (cached) return cached; + const preview = buildSimulatorEventPreview(event); + simulatorPreviewCache.set(event, preview); + return preview; +} + function rebuildSimulatorPreviewIndexes( cache: NormalizedSnapshotCache, simulatorEvents: SessionEvent[] @@ -90,7 +109,7 @@ function rebuildSimulatorPreviewIndexes( const displayVariantById: Record = {}; for (const event of simulatorEvents) { - eventPreviewById[event.id] = buildSimulatorEventPreview(event); + eventPreviewById[event.id] = previewForEvent(event); createdAtById[event.id] = event.createdAt; if (event.threadId) threadIdById[event.id] = event.threadId; functionNameById[event.id] = event.functionName; @@ -106,6 +125,68 @@ function rebuildSimulatorPreviewIndexes( cache.displayVariantById = displayVariantById; } +/** + * Copy-on-write patch of the preview index Records for changed simulator + * events: a Record whose entries are all value-identical keeps its object + * identity (pure-render consumers bail out); a touched Record is shallow- + * cloned exactly once. Only valid while the simulator id ordering is + * unchanged — membership changes must go through + * `rebuildSimulatorPreviewIndexes`. + */ +function patchSimulatorPreviewIndexes( + cache: NormalizedSnapshotCache, + simulatorEvents: SessionEvent[], + changedIds: ReadonlySet +): void { + let eventPreviewById: Record | null = null; + let createdAtById: Record | null = null; + let threadIdById: Record | null = null; + let functionNameById: Record | null = null; + let displayStatusById: Record | null = null; + let displayVariantById: Record | null = null; + + for (const event of simulatorEvents) { + if (!changedIds.has(event.id)) continue; + const preview = previewForEvent(event); + if (cache.eventPreviewById[event.id] !== preview) { + eventPreviewById ??= { ...cache.eventPreviewById }; + eventPreviewById[event.id] = preview; + } + if (cache.createdAtById[event.id] !== event.createdAt) { + createdAtById ??= { ...cache.createdAtById }; + createdAtById[event.id] = event.createdAt; + } + if (event.threadId) { + if (cache.threadIdById[event.id] !== event.threadId) { + threadIdById ??= { ...cache.threadIdById }; + threadIdById[event.id] = event.threadId; + } + } else if (event.id in cache.threadIdById) { + threadIdById ??= { ...cache.threadIdById }; + delete threadIdById[event.id]; + } + if (cache.functionNameById[event.id] !== event.functionName) { + functionNameById ??= { ...cache.functionNameById }; + functionNameById[event.id] = event.functionName; + } + if (cache.displayStatusById[event.id] !== event.displayStatus) { + displayStatusById ??= { ...cache.displayStatusById }; + displayStatusById[event.id] = event.displayStatus; + } + if (cache.displayVariantById[event.id] !== event.displayVariant) { + displayVariantById ??= { ...cache.displayVariantById }; + displayVariantById[event.id] = event.displayVariant; + } + } + + if (eventPreviewById) cache.eventPreviewById = eventPreviewById; + if (createdAtById) cache.createdAtById = createdAtById; + if (threadIdById) cache.threadIdById = threadIdById; + if (functionNameById) cache.functionNameById = functionNameById; + if (displayStatusById) cache.displayStatusById = displayStatusById; + if (displayVariantById) cache.displayVariantById = displayVariantById; +} + export function attachSimulatorPreviewFields( snapshot: TSnapshot, cache: NormalizedSnapshotCache @@ -203,31 +284,188 @@ export function materializeFullSnapshot( ); } -export function materializeSnapshot( +/** + * Accumulated effect of delta envelopes applied to a session's normalized + * cache since its last materialization. Scalars mirror the newest applied + * delta; `changedEventIds` and the order-changed flags accumulate so a + * single flush reconciles any number of envelopes without dropping one. + */ +export interface PendingDeltaState { + version: number; + eventCount: number; + chatEventCount: number; + hasRunningEvent: boolean; + latestCanvasPreview?: LatestCanvasPreview; + lastEventId: string | null; + /** Ids whose event object identity changed (upserted) since last flush. */ + changedEventIds: Set; + eventOrderChanged: boolean; + chatOrderChanged: boolean; + messagesOrderChanged: boolean; + simulatorOrderChanged: boolean; +} + +function sameIdList(previous: string[], next: string[]): boolean { + if (previous === next) return true; + if (previous.length !== next.length) return false; + for (let index = 0; index < previous.length; index++) { + if (previous[index] !== next[index]) return false; + } + return true; +} + +/** + * Apply one delta envelope to the cache. Must be called exactly once per + * envelope, in arrival order — envelopes are never dropped; only their + * materialization is coalesced (see EventStoreProxy). + */ +export function applyDeltaToCache( delta: SnapshotDelta, - cache: NormalizedSnapshotCache + cache: NormalizedSnapshotCache, + pending: PendingDeltaState | null +): PendingDeltaState { + const state: PendingDeltaState = pending ?? { + version: delta.version, + eventCount: delta.eventCount, + chatEventCount: delta.chatEventCount, + hasRunningEvent: delta.hasRunningEvent, + latestCanvasPreview: delta.latestCanvasPreview, + lastEventId: delta.lastEventId, + changedEventIds: new Set(), + eventOrderChanged: false, + chatOrderChanged: false, + messagesOrderChanged: false, + simulatorOrderChanged: false, + }; + + state.eventOrderChanged = + state.eventOrderChanged || !sameIdList(cache.eventIds, delta.eventIds); + state.chatOrderChanged = + state.chatOrderChanged || + !sameIdList(cache.chatEventIds, delta.chatEventIds); + state.messagesOrderChanged = + state.messagesOrderChanged || + !sameIdList(cache.messagesEventIds, delta.messagesEventIds); + state.simulatorOrderChanged = + state.simulatorOrderChanged || + !sameIdList(cache.sortedSimulatorEventIds, delta.sortedSimulatorEventIds); + + for (const removedId of delta.removedIds) { + cache.eventsById.delete(removedId); + state.changedEventIds.delete(removedId); + } + for (const event of delta.upserts) { + if (!isSessionEvent(event)) continue; + if (cache.eventsById.get(event.id) !== event) { + state.changedEventIds.add(event.id); + } + cache.eventsById.set(event.id, event); + } + + cache.eventIds = delta.eventIds; + cache.chatEventIds = delta.chatEventIds; + cache.messagesEventIds = delta.messagesEventIds; + cache.sortedSimulatorEventIds = delta.sortedSimulatorEventIds; + + state.version = delta.version; + state.eventCount = delta.eventCount; + state.chatEventCount = delta.chatEventCount; + state.hasRunningEvent = delta.hasRunningEvent; + state.latestCanvasPreview = delta.latestCanvasPreview; + state.lastEventId = delta.lastEventId; + return state; +} + +/** + * Pointer-copy `previousEvents`, swapping only the slots whose event object + * identity changed. Returns `previousEvents` untouched when no referenced + * event changed — zero allocation on the no-op path, zero per-event object + * construction on every path. + */ +function swapChangedEvents( + previousEvents: SessionEvent[], + cache: NormalizedSnapshotCache, + changedIds: ReadonlySet +): SessionEvent[] { + if (changedIds.size === 0) return previousEvents; + let next: SessionEvent[] | null = null; + for (let index = 0; index < previousEvents.length; index++) { + const current = previousEvents[index]; + if (!changedIds.has(current.id)) continue; + const updated = cache.eventsById.get(current.id); + if (!updated || updated === current) continue; + next ??= previousEvents.slice(); + next[index] = updated; + } + return next ?? previousEvents; +} + +/** + * Materialize accumulated delta state into a DerivedSnapshot, reusing every + * structure of `previous` whose inputs did not change: arrays are pointer- + * copied with only changed slots swapped, `eventIndex` survives unchanged + * orderings, and the preview Records are patched copy-on-write. Falls back + * to a full rebuild from the cache when no reusable DerivedSnapshot exists + * (e.g. the last remembered snapshot was a StreamingSnapshot). + */ +export function materializePendingDelta( + pending: PendingDeltaState, + cache: NormalizedSnapshotCache, + previous: Snapshot | null | undefined ): DerivedSnapshot { - const events = eventsForIds(cache, delta.eventIds); - const sortedSimulatorEvents = eventsForIds( - cache, - delta.sortedSimulatorEventIds - ); - rebuildSimulatorPreviewIndexes(cache, sortedSimulatorEvents); + const prev = + previous && !isStreamingSnapshot(previous) + ? (previous as DerivedSnapshot) + : null; + const changed = pending.changedEventIds; + + const events = + prev && !pending.eventOrderChanged + ? swapChangedEvents(prev.events, cache, changed) + : eventsForIds(cache, cache.eventIds); + const eventIndex = + prev && !pending.eventOrderChanged + ? prev.eventIndex + : buildEventIndex(events); + const chatEvents = + prev && !pending.chatOrderChanged + ? swapChangedEvents(prev.chatEvents, cache, changed) + : eventsForIds(cache, cache.chatEventIds); + const messagesEvents = + prev && !pending.messagesOrderChanged + ? swapChangedEvents(prev.messagesEvents, cache, changed) + : eventsForIds(cache, cache.messagesEventIds); + + let sortedSimulatorEvents: SessionEvent[]; + if (prev && !pending.simulatorOrderChanged) { + sortedSimulatorEvents = swapChangedEvents( + prev.sortedSimulatorEvents, + cache, + changed + ); + if (sortedSimulatorEvents !== prev.sortedSimulatorEvents) { + patchSimulatorPreviewIndexes(cache, sortedSimulatorEvents, changed); + } + } else { + sortedSimulatorEvents = eventsForIds(cache, cache.sortedSimulatorEventIds); + rebuildSimulatorPreviewIndexes(cache, sortedSimulatorEvents); + } + return attachSimulatorPreviewFields( { - version: delta.version, - eventCount: delta.eventCount, + version: pending.version, + eventCount: pending.eventCount, events, - chatEvents: eventsForIds(cache, delta.chatEventIds), - messagesEvents: eventsForIds(cache, delta.messagesEventIds), + chatEvents, + messagesEvents, sortedSimulatorEvents, - lastEvent: delta.lastEventId - ? (cache.eventsById.get(delta.lastEventId) ?? null) + lastEvent: pending.lastEventId + ? (cache.eventsById.get(pending.lastEventId) ?? null) : null, - eventIndex: buildEventIndex(events), - chatEventCount: delta.chatEventCount, - hasRunningEvent: delta.hasRunningEvent, - latestCanvasPreview: delta.latestCanvasPreview, + eventIndex, + chatEventCount: pending.chatEventCount, + hasRunningEvent: pending.hasRunningEvent, + latestCanvasPreview: pending.latestCanvasPreview, }, cache ); diff --git a/src/engines/SessionCore/core/store/useEventStoreBridge.ts b/src/engines/SessionCore/core/store/useEventStoreBridge.ts index 86f0fd537..238e7721f 100644 --- a/src/engines/SessionCore/core/store/useEventStoreBridge.ts +++ b/src/engines/SessionCore/core/store/useEventStoreBridge.ts @@ -36,9 +36,10 @@ function applyEventUpserts( previousEvents: SessionEvent[], previousEventIndex: Record, upserts: SessionEvent[] -): SessionEvent[] { - if (upserts.length === 0) return previousEvents; +): { events: SessionEvent[]; appended: boolean } { + if (upserts.length === 0) return { events: previousEvents, appended: false }; let nextEvents: SessionEvent[] | null = null; + let appended = false; for (const upsert of upserts) { const existingIndex = previousEventIndex[upsert.id]; @@ -50,10 +51,11 @@ function applyEventUpserts( } else { nextEvents ??= [...previousEvents]; nextEvents.push(upsert); + appended = true; } } - return nextEvents ?? previousEvents; + return { events: nextEvents ?? previousEvents, appended }; } function buildEventIndex(events: SessionEvent[]): Record { @@ -143,15 +145,16 @@ export function useEventStoreBridge(): void { // mid-stream, showing "(plan is empty)" until the turn completes. const prev = lastDerivedRef.current; if (prev) { - const events = applyEventUpserts( + const { events, appended } = applyEventUpserts( prev.events, prev.eventIndex, snapshot.simulatorEventUpserts ?? [] ); - const eventIndex = - events === prev.events - ? prev.eventIndex - : buildEventIndex(events); + // In-place replacements keep every id → index mapping valid; + // only appends (membership growth) require rebuilding. + const eventIndex = appended + ? buildEventIndex(events) + : prev.eventIndex; const sortedSimulatorEvents = snapshot.sortedSimulatorEventIds && snapshot.sortedSimulatorEventIds.length > 0 diff --git a/src/engines/SessionCore/core/types.ts b/src/engines/SessionCore/core/types.ts index 85565549c..bd8d7c40f 100644 --- a/src/engines/SessionCore/core/types.ts +++ b/src/engines/SessionCore/core/types.ts @@ -111,6 +111,29 @@ export interface SimulatorEventPreview { repoPath?: string; } +export const TOOL_USAGE_ARGS_KEY = "__orgiiToolUsage"; +export const LLM_USAGE_ARGS_KEY = "__orgiiLlmUsage"; + +export interface LlmUsageMetadata { + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheWriteTokens: number; + model?: string | null; + attributionMethod: string; +} + +export interface ToolUsageMetadata { + decisionCompletionTokens: number; + resultContextTokens: number; + followupCompletionTokens: number; + inputBytes: number; + outputBytes: number; + relatedCacheReadTokens: number; + relatedCacheWriteTokens: number; + attributionMethod: string; +} + export interface SessionEvent { chunk_id: string | null; // ============================================ @@ -179,6 +202,12 @@ export interface SessionEvent { /** Tool call ID for matching start/update/end events */ callId?: string; + /** Token/context attribution metadata for this tool call. */ + toolUsage?: ToolUsageMetadata; + + /** Token usage metadata for the LLM span represented by this event. */ + llmUsage?: LlmUsageMetadata; + /** File path for file operations */ filePath?: string; @@ -436,6 +465,8 @@ export interface RustOrgTaskItem { export interface RustExtractedOrgTaskData { action: "create" | "update" | "delete" | "get" | "list"; + /** Missing only on persisted events produced before operation outcomes existed. */ + outcome?: "pending" | "succeeded" | "rejected" | "failed" | "unknown"; task?: RustOrgTaskItem; tasks?: RustOrgTaskItem[]; total?: number; @@ -443,6 +474,8 @@ export interface RustExtractedOrgTaskData { ownerChanged?: boolean; statusChanged?: boolean; taskAssignedDispatched?: boolean; + guidance?: string; + errorMessage?: string; } export interface RustExtractedDeleteFileData { diff --git a/src/engines/SessionCore/derived/__tests__/chatEvents.test.ts b/src/engines/SessionCore/derived/__tests__/chatEvents.test.ts index 6d08b8b4f..74ea8cfeb 100644 --- a/src/engines/SessionCore/derived/__tests__/chatEvents.test.ts +++ b/src/engines/SessionCore/derived/__tests__/chatEvents.test.ts @@ -57,7 +57,10 @@ function setLiveContent( sessionId: string, content: string ) { - store.set(streamingDeltaContentAtom, new Map([[sessionId, content]])); + store.set( + streamingDeltaContentAtom, + new Map([[sessionId, { kind: "message" as const, content }]]) + ); } afterEach(() => { @@ -115,6 +118,23 @@ describe("chatEventsAtom live streaming overlay", () => { ]); }); + it("does not render live thinking as an Agent Station assistant message", () => { + const store = createStore(); + store.set(sessionIdAtom, "session-1"); + store.set(derivedSnapshotAtom, makeSnapshot()); + store.set( + streamingDeltaContentAtom, + new Map([ + [ + "session-1", + { kind: "thinking" as const, content: "reasoning token" }, + ], + ]) + ); + + expect(store.get(messagesEventsAtom)).toEqual([]); + }); + it("keeps the live assistant after existing thinking and preserves first-live timestamp", () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-06-06T20:01:00.000Z")); diff --git a/src/engines/SessionCore/derived/__tests__/planDisplayEvents.test.ts b/src/engines/SessionCore/derived/__tests__/planDisplayEvents.test.ts index 27320fddc..d01731083 100644 --- a/src/engines/SessionCore/derived/__tests__/planDisplayEvents.test.ts +++ b/src/engines/SessionCore/derived/__tests__/planDisplayEvents.test.ts @@ -309,6 +309,41 @@ describe("plan display event derivation", () => { expect(derived[0].result.status).toBe("cancelled"); }); + it("does not label a cancelled plan as drafting when the raw draft is still running", () => { + const raw = rawCreatePlan({ + id: "tool-call-call_1", + callId: "call_1", + displayStatus: "running", + createdAt: "2026-05-15T00:00:00.000Z", + }); + const cancelled = planApproval({ + id: "call_1-cancelled", + callId: "call_1", + result: { + status: "cancelled", + planId: "plan-1", + planRevisionId: "call_1", + planPath: "/tmp/plan.md", + }, + displayStatus: "completed", + createdAt: "2026-05-15T00:00:01.000Z", + }); + + const [displayEvent] = derivePlanDisplayEvents([raw, cancelled]); + const viewState = derivePlanApprovalViewState({ + pendingPlan: null, + chatEvents: [displayEvent], + }); + + expect(displayEvent.result.status).toBe("cancelled"); + expect(viewState.getEventState(displayEvent, "transcript")).toMatchObject({ + status: "cancelled", + readyForReview: false, + actionable: false, + label: "skipped", + }); + }); + it("does not render rehydrated pending-plan snapshots in transcript history", () => { const rehydratedApproval = planApproval({ id: "call_1", diff --git a/src/engines/SessionCore/derived/__tests__/sessionScopedChatEventsRetention.test.ts b/src/engines/SessionCore/derived/__tests__/sessionScopedChatEventsRetention.test.ts new file mode 100644 index 000000000..ea8b75140 --- /dev/null +++ b/src/engines/SessionCore/derived/__tests__/sessionScopedChatEventsRetention.test.ts @@ -0,0 +1,115 @@ +/** + * Session-scoped atom family retention tests + * + * Pins the family GC contract: jotai-family pins every created atom in a + * strong Map, so entries must be explicitly removed once the session's + * snapshot atom has been unmounted for SESSION_FAMILY_RETAIN_MS — and must + * NOT be removed while mounted or when remounted within the grace period. + * Removal is observable as an identity change: the family returns a fresh + * atom object for the same sessionId after its entry was removed. + */ +import { createStore } from "jotai"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + SESSION_FAMILY_RETAIN_MS, + chatEventsForSessionAtomFamily, + sessionScopedPlanningMetaAtomFamily, +} from "@src/engines/SessionCore/derived/sessionScopedChatEvents"; + +const subscribers = new Map void>(); + +vi.mock("@src/engines/SessionCore/core/store/EventStoreProxy", () => ({ + eventStoreProxy: { + getLatestSessionSnapshot: () => null, + subscribeSession: ( + sessionId: string, + listener: (snapshot: unknown) => void + ) => { + subscribers.set(sessionId, listener); + return () => subscribers.delete(sessionId); + }, + loadFromCache: () => Promise.resolve(), + }, + isStreamingSnapshot: (snapshot: unknown) => + Boolean((snapshot as { streaming?: boolean })?.streaming), +})); + +describe("session-scoped atom family retention", () => { + let store: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + store = createStore(); + }); + + afterEach(() => { + vi.useRealTimers(); + subscribers.clear(); + }); + + it("removes family entries after the retain window once unmounted", () => { + const sessionId = "retention-removed"; + const chatAtomBefore = chatEventsForSessionAtomFamily(sessionId); + const metaAtomBefore = sessionScopedPlanningMetaAtomFamily(sessionId); + + const unsub = store.sub(chatAtomBefore, () => {}); + unsub(); + vi.advanceTimersByTime(SESSION_FAMILY_RETAIN_MS + 1); + + expect(chatEventsForSessionAtomFamily(sessionId)).not.toBe(chatAtomBefore); + expect(sessionScopedPlanningMetaAtomFamily(sessionId)).not.toBe( + metaAtomBefore + ); + }); + + it("keeps family entries while still mounted", () => { + const sessionId = "retention-mounted"; + const chatAtomBefore = chatEventsForSessionAtomFamily(sessionId); + + const unsub = store.sub(chatAtomBefore, () => {}); + vi.advanceTimersByTime(SESSION_FAMILY_RETAIN_MS * 3); + + expect(chatEventsForSessionAtomFamily(sessionId)).toBe(chatAtomBefore); + unsub(); + }); + + it("cancels a pending removal when remounted within the grace period", () => { + const sessionId = "retention-remounted"; + const chatAtomBefore = chatEventsForSessionAtomFamily(sessionId); + + const firstMount = store.sub(chatAtomBefore, () => {}); + firstMount(); + vi.advanceTimersByTime(SESSION_FAMILY_RETAIN_MS / 2); + + const secondMount = store.sub( + chatEventsForSessionAtomFamily(sessionId), + () => {} + ); + vi.advanceTimersByTime(SESSION_FAMILY_RETAIN_MS * 2); + + expect(chatEventsForSessionAtomFamily(sessionId)).toBe(chatAtomBefore); + secondMount(); + }); + + it("restarts the grace period on each unmount", () => { + const sessionId = "retention-restarted"; + const chatAtomBefore = chatEventsForSessionAtomFamily(sessionId); + + const firstMount = store.sub(chatAtomBefore, () => {}); + firstMount(); + vi.advanceTimersByTime(SESSION_FAMILY_RETAIN_MS / 2); + + // Remount + unmount again: the clock restarts from the second unmount. + const secondMount = store.sub( + chatEventsForSessionAtomFamily(sessionId), + () => {} + ); + secondMount(); + vi.advanceTimersByTime(SESSION_FAMILY_RETAIN_MS - 1); + expect(chatEventsForSessionAtomFamily(sessionId)).toBe(chatAtomBefore); + + vi.advanceTimersByTime(2); + expect(chatEventsForSessionAtomFamily(sessionId)).not.toBe(chatAtomBefore); + }); +}); diff --git a/src/engines/SessionCore/derived/__tests__/sessionScopedPlanningMeta.test.ts b/src/engines/SessionCore/derived/__tests__/sessionScopedPlanningMeta.test.ts index fef70310b..1dd783eab 100644 --- a/src/engines/SessionCore/derived/__tests__/sessionScopedPlanningMeta.test.ts +++ b/src/engines/SessionCore/derived/__tests__/sessionScopedPlanningMeta.test.ts @@ -100,6 +100,7 @@ describe("sessionScopedPlanningMetaAtomFamily", () => { version: 0, anyRunning: false, hasAwaitingUserInteraction: false, + hasRunningAwaitWaitFor: false, }); }); @@ -162,6 +163,7 @@ describe("sessionScopedPlanningMetaAtomFamily", () => { version: 0, anyRunning: false, hasAwaitingUserInteraction: false, + hasRunningAwaitWaitFor: false, }); }); }); diff --git a/src/engines/SessionCore/derived/chatEvents.ts b/src/engines/SessionCore/derived/chatEvents.ts index 6dfd558ce..a3fcd38e9 100644 --- a/src/engines/SessionCore/derived/chatEvents.ts +++ b/src/engines/SessionCore/derived/chatEvents.ts @@ -175,9 +175,10 @@ export const chatEventsAtom = atom((get) => { // During streaming, inject the live-assistant placeholder with a stable // sentinel so `appendLiveAssistantEvent` adds it to the list without - // subscribing to `streamingDeltaContentAtom` (which fires on every token). - // The actual streaming text is read directly from `streamingDeltaContentAtom` - // inside `AgentMessageEvent` at the leaf renderer level. + // subscribing to `streamingDeltaContentAtom` (which fires on every + // buffered flush, ≤20Hz mid-stream). The actual streaming text is read + // directly from `streamingDeltaContentAtom` inside `AgentMessageEvent` at + // the leaf renderer level. const streaming = snap ? isStreamingSnap(snap) : false; const liveContent = streaming && sessionId ? "\u200b" : null; const queuedMessages = get(messageQueueAtom); diff --git a/src/engines/SessionCore/derived/planDisplayEvents.ts b/src/engines/SessionCore/derived/planDisplayEvents.ts index 62c84a696..ff5722299 100644 --- a/src/engines/SessionCore/derived/planDisplayEvents.ts +++ b/src/engines/SessionCore/derived/planDisplayEvents.ts @@ -439,10 +439,10 @@ function planSurfaceLabel(options: { readyForReview: boolean; isStreaming: boolean; }): PlanStateLabel { - if (options.isStreaming) return "drafting"; if (options.status === "approved") return "built"; if (options.status === "archived") return "archived"; if (options.status === "cancelled") return "skipped"; + if (options.isStreaming) return "drafting"; if (options.readyForReview) return "ready"; return "idle"; } diff --git a/src/engines/SessionCore/derived/planningIndicatorAtoms.ts b/src/engines/SessionCore/derived/planningIndicatorAtoms.ts index c330b476d..d4b0a8cf7 100644 --- a/src/engines/SessionCore/derived/planningIndicatorAtoms.ts +++ b/src/engines/SessionCore/derived/planningIndicatorAtoms.ts @@ -15,7 +15,10 @@ import { atom } from "jotai"; import { derivedSnapshotAtom } from "../core/atoms/events"; import { isInteractiveTool } from "../core/interactiveTools"; -import { hasLiveRuntimeResourceInLatestTurn } from "../core/runningEventGate"; +import { + hasLiveRuntimeResourceInLatestTurn, + hasRunningAwaitWaitForInLatestTurn, +} from "../core/runningEventGate"; /** * True when the latest agent turn has at least one live runtime resource @@ -29,6 +32,20 @@ export const globalAnyRunningAtom = atom((get) => { }); globalAnyRunningAtom.debugLabel = "planning/globalAnyRunning"; +/** + * True when the latest turn has a still-running `await_output` wait_for call. + * Its own live "Waiting {countdown} for …" title already conveys activity, so + * the planning footer is suppressed in this window to avoid two stacked + * waiting indicators. Changes only when the wait_for starts/ends. + */ +export const globalHasRunningAwaitWaitForAtom = atom((get) => { + const snapshot = get(derivedSnapshotAtom); + if (!snapshot || !("chatEvents" in snapshot)) return false; + return hasRunningAwaitWaitForInLatestTurn(snapshot.chatEvents); +}); +globalHasRunningAwaitWaitForAtom.debugLabel = + "planning/globalHasRunningAwaitWaitFor"; + /** * True when there is a pending interactive tool call awaiting user input. * Changes only when an interactive event arrives or is processed, not on diff --git a/src/engines/SessionCore/derived/sessionScopedChatEvents.ts b/src/engines/SessionCore/derived/sessionScopedChatEvents.ts index ef930b977..f8a0820e6 100644 --- a/src/engines/SessionCore/derived/sessionScopedChatEvents.ts +++ b/src/engines/SessionCore/derived/sessionScopedChatEvents.ts @@ -20,9 +20,10 @@ * - The subscription is established eagerly in `onMount` and cleaned up in * the returned disposer; a `getLatestSessionSnapshot` poll closes the race * between mount and the next push. - * - When the family entry is garbage-collected (no subscriber), the disposer - * tears down the per-session subscription. The Rust EventStore keeps its - * own LRU cache; we only mirror what is currently mounted. + * - Family entries are explicitly removed (jotai-family pins them in a + * strong Map otherwise) once the snapshot atom has been unmounted for + * SESSION_FAMILY_RETAIN_MS. The Rust EventStore keeps its own LRU cache; + * we only mirror what is mounted or recently unmounted. */ import { atom } from "jotai"; import { atomFamily } from "jotai-family"; @@ -30,7 +31,10 @@ import { atomFamily } from "jotai-family"; import { createLogger } from "@src/hooks/logger"; import { isInteractiveTool } from "../core/interactiveTools"; -import { hasLiveRuntimeResourceInLatestTurn } from "../core/runningEventGate"; +import { + hasLiveRuntimeResourceInLatestTurn, + hasRunningAwaitWaitForInLatestTurn, +} from "../core/runningEventGate"; import type { Snapshot } from "../core/store/EventStoreProxy"; import { eventStoreProxy, @@ -54,6 +58,42 @@ const EMPTY_STATE: SnapshotState = { loadStarted: false, }; +/** + * Family GC. `jotai-family` pins every created atom in a strong Map until + * `remove()` is called, so without this each subagent session ever rendered + * would keep its last full snapshot (and derived chat-events array) on the + * heap for the app lifetime. Entries are released once the session's + * snapshot atom has been unmounted for SESSION_FAMILY_RETAIN_MS — long + * enough that grid-layout churn and quick switch-backs stay warm, short + * enough that hopping across many replays doesn't accumulate transcripts. + * + * Removal is mount-gated and therefore glitch-free: the two derived families + * read `sessionSnapshotAtomFamily`, so none of the three can still be + * mounted when the snapshot atom's unmount cleanup fires, and a remount + * within the grace period cancels the pending removal. + */ +export const SESSION_FAMILY_RETAIN_MS = 3 * 60 * 1000; + +const familyRemovalTimers = new Map>(); + +function cancelSessionFamilyRemoval(sessionId: string): void { + const timer = familyRemovalTimers.get(sessionId); + if (timer === undefined) return; + clearTimeout(timer); + familyRemovalTimers.delete(sessionId); +} + +function scheduleSessionFamilyRemoval(sessionId: string): void { + cancelSessionFamilyRemoval(sessionId); + const timer = setTimeout(() => { + familyRemovalTimers.delete(sessionId); + chatEventsForSessionAtomFamily.remove(sessionId); + sessionScopedPlanningMetaAtomFamily.remove(sessionId); + sessionSnapshotAtomFamily.remove(sessionId); + }, SESSION_FAMILY_RETAIN_MS); + familyRemovalTimers.set(sessionId, timer); +} + /** * Backing snapshot atom for a single subagent session. * @@ -68,6 +108,7 @@ const sessionSnapshotAtomFamily = atomFamily((sessionId: string) => { a.onMount = (setSelf) => { let disposed = false; + cancelSessionFamilyRemoval(sessionId); setSelf((prev) => { if (prev.loadStarted) return prev; @@ -103,6 +144,7 @@ const sessionSnapshotAtomFamily = atomFamily((sessionId: string) => { return () => { disposed = true; unsubscribe(); + scheduleSessionFamilyRemoval(sessionId); }; }; @@ -183,12 +225,18 @@ export interface SessionScopedPlanningMeta { anyRunning: boolean; /** True while an interactive tool is blocked waiting for user input. */ hasAwaitingUserInteraction: boolean; + /** + * True while the latest turn has a still-running `await_output` wait_for — + * its own live countdown title makes the planning footer redundant. + */ + hasRunningAwaitWaitFor: boolean; } const EMPTY_PLANNING_META: SessionScopedPlanningMeta = { version: 0, anyRunning: false, hasAwaitingUserInteraction: false, + hasRunningAwaitWaitFor: false, }; export const sessionScopedPlanningMetaAtomFamily = atomFamily( @@ -208,11 +256,13 @@ export const sessionScopedPlanningMetaAtomFamily = atomFamily( event.activityStatus !== "processed" && isInteractiveTool(event.functionName) ), + hasRunningAwaitWaitFor: hasRunningAwaitWaitForInLatestTurn(chatEvents), }; if ( next.version === prev.version && next.anyRunning === prev.anyRunning && - next.hasAwaitingUserInteraction === prev.hasAwaitingUserInteraction + next.hasAwaitingUserInteraction === prev.hasAwaitingUserInteraction && + next.hasRunningAwaitWaitFor === prev.hasRunningAwaitWaitFor ) { return prev; } diff --git a/src/engines/SessionCore/derived/simulatorEvents.ts b/src/engines/SessionCore/derived/simulatorEvents.ts index 3bccebab6..f555fdedf 100644 --- a/src/engines/SessionCore/derived/simulatorEvents.ts +++ b/src/engines/SessionCore/derived/simulatorEvents.ts @@ -118,9 +118,10 @@ simulatorEventsAtom.debugLabel = "session/simulatorEvents"; export const messagesEventsAtom = atom((get) => { const snap = get(derivedSnapshotAtom); const sessionId = get(sessionIdAtom); - const liveContent = sessionId + const liveDelta = sessionId ? (get(streamingDeltaContentAtom).get(sessionId) ?? null) : null; + const liveContent = liveDelta?.kind === "message" ? liveDelta.content : null; if (snap && "messagesEvents" in snap) { return appendLiveAssistantEvent( diff --git a/src/engines/SessionCore/hooks/adeReplyBinding.test.ts b/src/engines/SessionCore/hooks/adeReplyBinding.test.ts new file mode 100644 index 000000000..7a01fe21e --- /dev/null +++ b/src/engines/SessionCore/hooks/adeReplyBinding.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; + +import { ACTION_ID } from "@src/ActionSystem/actionIds"; + +import { + extractInvokingSessionId, + resolveTrustedDispatchParams, +} from "./adeReplyBinding"; + +describe("extractInvokingSessionId", () => { + it("reads a string invokingSessionId from the envelope", () => { + expect(extractInvokingSessionId({ invokingSessionId: "ls-owner" })).toBe( + "ls-owner" + ); + }); + + it("reads an empty string as an empty string", () => { + expect(extractInvokingSessionId({ invokingSessionId: "" })).toBe(""); + }); + + it("returns undefined when absent or non-string", () => { + expect(extractInvokingSessionId({})).toBeUndefined(); + expect(extractInvokingSessionId({ invokingSessionId: 42 })).toBeUndefined(); + }); +}); + +describe("resolveTrustedDispatchParams", () => { + it("overwrites an agent-supplied localSessionId with the trusted envelope id", () => { + const spoofed = { + commentId: "b-1", + body: "forged", + localSessionId: "ls-victim", + }; + const resolved = resolveTrustedDispatchParams( + ACTION_ID.SESSION_REPLY_COMMENT, + spoofed, + "ls-owner" + ); + expect(resolved.localSessionId).toBe("ls-owner"); + expect(resolved.commentId).toBe("b-1"); + expect(resolved.body).toBe("forged"); + }); + + it("forces localSessionId empty when the envelope carries no trusted id (raw control_orgii dispatch)", () => { + const spoofed = { + commentId: "b-1", + body: "forged", + localSessionId: "ls-victim", + }; + expect( + resolveTrustedDispatchParams( + ACTION_ID.SESSION_REPLY_COMMENT, + spoofed, + undefined + ).localSessionId + ).toBe(""); + expect( + resolveTrustedDispatchParams(ACTION_ID.SESSION_REPLY_COMMENT, spoofed, "") + .localSessionId + ).toBe(""); + }); + + it("does not add a localSessionId to non-reply actions", () => { + const params = { foo: "bar" }; + const resolved = resolveTrustedDispatchParams( + ACTION_ID.GUI_EXECUTE, + params, + "ls-owner" + ); + expect(resolved).toBe(params); + expect(resolved.localSessionId).toBeUndefined(); + }); +}); diff --git a/src/engines/SessionCore/hooks/adeReplyBinding.ts b/src/engines/SessionCore/hooks/adeReplyBinding.ts new file mode 100644 index 000000000..5130653e5 --- /dev/null +++ b/src/engines/SessionCore/hooks/adeReplyBinding.ts @@ -0,0 +1,17 @@ +import { ACTION_ID } from "@src/ActionSystem/actionIds"; + +export function extractInvokingSessionId( + payload: Record +): string | undefined { + const value = payload.invokingSessionId; + return typeof value === "string" ? value : undefined; +} + +export function resolveTrustedDispatchParams( + action: string | undefined, + params: Record, + invokingSessionId: string | undefined +): Record { + if (action !== ACTION_ID.SESSION_REPLY_COMMENT) return params; + return { ...params, localSessionId: invokingSessionId ?? "" }; +} diff --git a/src/engines/SessionCore/hooks/replay/usePlanningIndicator.test.ts b/src/engines/SessionCore/hooks/replay/usePlanningIndicator.test.ts index 3bfdc4dfc..8ee98245d 100644 --- a/src/engines/SessionCore/hooks/replay/usePlanningIndicator.test.ts +++ b/src/engines/SessionCore/hooks/replay/usePlanningIndicator.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; -import { shouldShowPlanningIndicator } from "./usePlanningIndicator"; +import { + planningWatchdogDelayMs, + shouldShowPlanningIndicator, +} from "./usePlanningIndicator"; const baseInput = { runtimeStatus: "running", @@ -12,6 +15,7 @@ const baseInput = { idleAfterVersion: 10, version: 10, hasLiveSubagent: false, + hasRunningAwaitWaitFor: false, }; describe("shouldShowPlanningIndicator", () => { @@ -48,13 +52,13 @@ describe("shouldShowPlanningIndicator", () => { ).toBe(true); }); - it("hides while a visible running row is painted", () => { + it("shows while a running tool row is idle long enough", () => { expect( shouldShowPlanningIndicator({ ...baseInput, anyRunning: true, }) - ).toBe(false); + ).toBe(true); }); it("shows during the parent gap when a background subagent is still running", () => { @@ -69,7 +73,7 @@ describe("shouldShowPlanningIndicator", () => { ).toBe(true); }); - it("does not show on a live subagent if a visible running row is already painted", () => { + it("shows on a live subagent after a running row becomes idle", () => { expect( shouldShowPlanningIndicator({ ...baseInput, @@ -77,6 +81,56 @@ describe("shouldShowPlanningIndicator", () => { hasLiveSubagent: true, anyRunning: true, }) + ).toBe(true); + }); + + it("hides while a running await_output shows its own loading title", () => { + // Any await_output (wait_for or monitor) renders a live shimmer title, so + // the planning footer would be a redundant second activity indicator. + expect( + shouldShowPlanningIndicator({ + ...baseInput, + hasRunningAwaitWaitFor: true, + }) + ).toBe(false); + }); + + it("still hides the footer during await_output even if a subagent is live", () => { + expect( + shouldShowPlanningIndicator({ + ...baseInput, + runtimeStatus: "idle", + hasLiveSubagent: true, + hasRunningAwaitWaitFor: true, + }) ).toBe(false); }); }); + +describe("planningWatchdogDelayMs", () => { + const WATCHDOG = 60_000; + + it("trips when no channel event was ever observed", () => { + expect(planningWatchdogDelayMs(null, WATCHDOG)).toBeNull(); + }); + + it("trips when the channel has been silent for the full window", () => { + expect(planningWatchdogDelayMs(WATCHDOG, WATCHDOG)).toBeNull(); + expect(planningWatchdogDelayMs(WATCHDOG + 5_000, WATCHDOG)).toBeNull(); + }); + + it("re-arms for the remainder while ephemeral deltas keep the channel busy", () => { + // tool_call_delta arrived 1s ago (never bumps the store version) — + // probe again in 59s rather than force-completing a live turn. + expect(planningWatchdogDelayMs(1_000, WATCHDOG)).toBe(59_000); + }); + + it("re-arms with a shrinking window as silence grows", () => { + expect(planningWatchdogDelayMs(59_999, WATCHDOG)).toBe(1); + }); + + it("clamps negative recency (clock skew) to a full window", () => { + // msSinceSessionChannelActivity floors at 0, but guard the policy too. + expect(planningWatchdogDelayMs(0, WATCHDOG)).toBe(WATCHDOG); + }); +}); diff --git a/src/engines/SessionCore/hooks/replay/usePlanningIndicator.ts b/src/engines/SessionCore/hooks/replay/usePlanningIndicator.ts index 8acd6b693..c7bfb6698 100644 --- a/src/engines/SessionCore/hooks/replay/usePlanningIndicator.ts +++ b/src/engines/SessionCore/hooks/replay/usePlanningIndicator.ts @@ -4,21 +4,9 @@ * Shows a single "Planning next step..." line in the chat panel when: * 1. Any session type is actively working (code / cloud / OS agent) * 2. No store mutations for IDLE_THRESHOLD_MS (1 second) - * 3. No event currently has displayStatus === "running" * * The indicator stays visible until new events arrive or the session ends. * - * After PLANNING_SLOW_HINT_MS (10s) with the indicator still visible, - * `showSlowHint` becomes true (e.g. "Taking longer than usual.") — UNLESS - * the most recent chat-visible event is already a settled assistant message, - * in which case the slow hint is suppressed. From the user's point of view - * the agent has just finished talking; promoting the footer to "taking - * longer than usual" while the turn executor is doing its post-batch - * "anything else?" LLM round trip is misleading. The base indicator itself - * is NOT suppressed in that state: mid-turn the agent routinely narrates - * (settled assistant message) and then thinks for seconds before the next - * tool call, and hiding the footer there reads as a frozen UI. - * * Watchdog: if the indicator stays visible for PLANNING_WATCHDOG_MS (60s), * we assume Rust dropped `agent:complete` (or `agent:queue_status` idle) * and force `sessionRuntimeStatusAtom` to `completed` so the UI cannot stay @@ -29,6 +17,8 @@ * Rust pushes StreamingSnapshot which has no `events` field, causing eventsAtom * to return []. Both snapshot types now carry `hasRunningEvent` (computed * against ALL events, including non-chat-visible ones like thinking deltas). + * Running events are used only to keep the watchdog from force-completing a + * legitimate long tool call; they do not suppress the idle footer. * * Uses snapshot `version` as the activity token — it bumps on every store * mutation (upsert, append, merge), including streaming deltas for thinking @@ -53,18 +43,20 @@ import { sessionIdAtom } from "@src/engines/SessionCore/core/atoms/metadata"; import { globalAnyRunningAtom, globalHasAwaitingUserInteractionAtom, - globalLastIsSettledAssistantMessageAtom, + globalHasRunningAwaitWaitForAtom, } from "@src/engines/SessionCore/derived/planningIndicatorAtoms"; import { noopSessionScopedPlanningMetaAtom, sessionScopedPlanningMetaAtomFamily, } from "@src/engines/SessionCore/derived/sessionScopedChatEvents"; +import { msSinceSessionChannelActivity } from "@src/engines/SessionCore/sync/sessionChannelActivity"; import { createLogger } from "@src/hooks/logger"; import { isPendingCancelAtom, isSessionActiveAtom, sessionRuntimeStatusAtom, setSessionRuntimeStatusAtom, + streamRetryStatusAtom, } from "@src/store/session/cliSessionStatusAtom"; import { hasLiveSubagentJobs, @@ -76,9 +68,6 @@ const log = createLogger("usePlanningIndicator"); /** How long (ms) to wait without new events before showing the indicator */ const IDLE_THRESHOLD_MS = 1000; -/** How long (ms) the planning indicator must stay visible before showing the slow hint */ -const PLANNING_SLOW_HINT_MS = 10_000; - /** * How long (ms) the planning indicator may stay visible before the watchdog * force-completes the session. Generous because legitimate "LLM is wrapping @@ -87,6 +76,31 @@ const PLANNING_SLOW_HINT_MS = 10_000; */ const PLANNING_WATCHDOG_MS = 60_000; +/** + * Decide what the watchdog should do when its timer fires, given how long + * ago the session's IPC channel last delivered ANY event. + * + * Returns `null` to trip (fire the force-complete), or a positive delay in + * ms to re-arm for. Pure so the recency policy is unit-testable without + * faking React timers. + * + * - `null` recency (no event observed since app start) trips: with the + * channel silent for the whole watchdog window there is nothing to prove + * the backend is alive, which is exactly the event-loss case the watchdog + * exists for. + * - Recent activity re-arms for the REMAINDER of the window so a turn that + * streams ephemeral deltas (never bumping the store version) is probed at + * the right moment instead of on a fixed cadence. + */ +export function planningWatchdogDelayMs( + msSinceChannelActivity: number | null, + watchdogMs: number = PLANNING_WATCHDOG_MS +): number | null { + if (msSinceChannelActivity === null) return null; + if (msSinceChannelActivity >= watchdogMs) return null; + return watchdogMs - msSinceChannelActivity; +} + export interface PlanningIndicatorVisibilityInput { runtimeStatus: string; isSessionActive: boolean; @@ -103,6 +117,12 @@ export interface PlanningIndicatorVisibilityInput { * would vanish during that gap even though work is clearly ongoing. */ hasLiveSubagent: boolean; + /** + * True while the latest turn has a still-running `await_output` wait_for. + * That call renders its own live "Waiting {countdown} for …" title, so the + * planning footer is suppressed to avoid two stacked waiting indicators. + */ + hasRunningAwaitWaitFor: boolean; } export function shouldShowPlanningIndicator({ @@ -110,11 +130,11 @@ export function shouldShowPlanningIndicator({ isSessionActive, isPendingCancel, hasAwaitingUserInteraction, - anyRunning, coldStartVisible, idleAfterVersion, version, hasLiveSubagent, + hasRunningAwaitWaitFor, }: PlanningIndicatorVisibilityInput): boolean { const runtimeCanShowPlanning = runtimeStatus === "running" || @@ -127,7 +147,7 @@ export function shouldShowPlanningIndicator({ isSessionActive && !isPendingCancel && !hasAwaitingUserInteraction && - !anyRunning && + !hasRunningAwaitWaitFor && (coldStartVisible || idleAfterVersion === version) ); } @@ -135,14 +155,11 @@ export function shouldShowPlanningIndicator({ export interface PlanningIndicatorState { /** 1 when the planning footer should show, 0 when hidden */ count: 0 | 1; - /** True after the indicator has been visible for PLANNING_SLOW_HINT_MS */ - showSlowHint: boolean; /** * Stable random index used by the footer to pick one phrasing variant * from the localized variant array. Re-rolled every time the indicator * transitions hidden → visible; stays fixed for the whole visible span - * (including the slow-hint transition at 10s) so the text does not - * shuffle mid-wait. + * so the text does not shuffle mid-wait. */ variantIndex: number; } @@ -178,6 +195,7 @@ export function usePlanningIndicator( const globalVersion = useAtomValue(eventStoreVersionAtom); const sessionId = useAtomValue(sessionIdAtom); const subagentJobMap = useAtomValue(subagentJobMapAtom); + const streamRetryStatus = useAtomValue(streamRetryStatusAtom); const setSessionRuntimeStatus = useSetAtom(setSessionRuntimeStatusAtom); const scopedMeta = useAtomValue( scope @@ -210,17 +228,14 @@ export function usePlanningIndicator( ? scopedMeta.hasAwaitingUserInteraction : globalHasAwaitingUserInteraction; - // True when the most recent chat-visible event is a non-streaming - // assistant message that has already settled. In this state the user - // has seen the final reply, so showing a planning footer is misleading - // even if the backend terminal event is still winding down. - // The derived atom only fires when the value actually changes, not every token. - const globalLastIsSettledAssistantMessage = useAtomValue( - globalLastIsSettledAssistantMessageAtom + // Running wait_for in the latest turn → its own countdown title is the + // activity signal; suppress the duplicate planning footer. + const globalHasRunningAwaitWaitFor = useAtomValue( + globalHasRunningAwaitWaitForAtom ); - const lastIsSettledAssistantMessage = scoped - ? false - : globalLastIsSettledAssistantMessage; + const hasRunningAwaitWaitFor = scoped + ? scopedMeta.hasRunningAwaitWaitFor + : globalHasRunningAwaitWaitFor; const [idleAfterVersion, setIdleAfterVersion] = useState(null); const idleTimerRef = useRef | null>(null); @@ -289,7 +304,7 @@ export function usePlanningIndicator( }; }, [isSessionActive, version, activationVersion]); - // Visible when: session active, no running event, not pending cancel, AND either + // Visible when: session active, not pending cancel, AND either // (a) cold-start — version hasn't bumped since activation yet, OR // (b) warm — IDLE_THRESHOLD_MS elapsed since last mutation. // @@ -306,6 +321,10 @@ export function usePlanningIndicator( const hasLiveSubagent = scoped ? false : hasLiveSubagentJobs(subagentJobMap, sessionId); + // Backoff wait between LLM stream retries — silent by design, must not + // count as a stall (rate-limit backoffs can exceed the watchdog window). + const hasPendingStreamRetry = + !scoped && streamRetryStatus?.sessionId === sessionId; const visible = shouldShowPlanningIndicator({ runtimeStatus, isSessionActive, @@ -316,29 +335,24 @@ export function usePlanningIndicator( idleAfterVersion, version, hasLiveSubagent, + hasRunningAwaitWaitFor, }); - const [showSlowHint, setShowSlowHint] = useState(false); - - useEffect(() => { - if (!visible) { - return; - } - const timerId = window.setTimeout(() => { - setShowSlowHint(true); - }, PLANNING_SLOW_HINT_MS); - return () => { - window.clearTimeout(timerId); - setShowSlowHint(false); - }; - }, [visible]); - // Watchdog: force-complete the session if the planning indicator stays // visible past PLANNING_WATCHDOG_MS. Any new store mutation flips // `visible` to false (via the idle-timer arm in the effect above), // which cancels this timer; on the next idle the watchdog re-arms. // We only trip on genuine "no activity at all" stalls. // + // "Activity" is CHANNEL activity, not store mutations: several event + // classes are deliberately ephemeral and never bump the store version — + // `agent:tool_call_delta` buffers in memory only (a model can spend + // minutes streaming one large edit_file call), `agent:stream_retry` + // writes a side atom. When the timer fires we therefore consult + // `msSinceSessionChannelActivity` and re-arm for the remaining window + // instead of tripping while the backend is demonstrably alive + // (see planningWatchdogDelayMs). + // // UI-only: this clears the runtime-status mirror so the footer stops // saying "Planning…", but deliberately does NOT touch the turn-lifecycle // FSM. A long quiet stretch (subagent wait, slow tool) is not proof the @@ -357,24 +371,57 @@ export function usePlanningIndicator( // the parent status to "completed" mid-wait — the child's // `agent:subagent_job_changed` terminal event is the real completion // signal, not a 60s wall clock. + // + // hasPendingStreamRetry guard: `agent:stream_retry` means the backend is + // deliberately waiting out a backoff (rate-limit backoffs can exceed the + // watchdog window) with zero events by design. The retry pill is the + // user-visible activity indicator; the recovery/exhausted event re-arms + // normal watchdog coverage. useEffect(() => { - if (scoped || !visible || !sessionId || hasLiveSubagent) return; - const timerId = window.setTimeout(() => { - log.warn( - `[usePlanningIndicator] watchdog: planning indicator stuck for ${PLANNING_WATCHDOG_MS}ms — ` + - "forcing session status to 'completed'. This usually means Rust dropped agent:complete " + - "or the idle agent:queue_status frame." - ); - setSessionRuntimeStatus({ - sessionId, - status: "completed", - source: "planning", - }); - }, PLANNING_WATCHDOG_MS); + if ( + scoped || + !visible || + !sessionId || + hasLiveSubagent || + anyRunning || + hasPendingStreamRetry + ) + return; + let timerId: number | null = null; + const arm = (delayMs: number) => { + timerId = window.setTimeout(() => { + const rearmDelay = planningWatchdogDelayMs( + msSinceSessionChannelActivity(sessionId) + ); + if (rearmDelay !== null) { + arm(rearmDelay); + return; + } + log.warn( + `[usePlanningIndicator] watchdog: planning indicator stuck for ${PLANNING_WATCHDOG_MS}ms ` + + "with no channel activity — forcing session status to 'completed'. This usually means " + + "Rust dropped agent:complete or the idle agent:queue_status frame." + ); + setSessionRuntimeStatus({ + sessionId, + status: "completed", + source: "planning", + }); + }, delayMs); + }; + arm(PLANNING_WATCHDOG_MS); return () => { - window.clearTimeout(timerId); + if (timerId !== null) window.clearTimeout(timerId); }; - }, [scoped, visible, sessionId, hasLiveSubagent, setSessionRuntimeStatus]); + }, [ + scoped, + visible, + sessionId, + hasLiveSubagent, + anyRunning, + hasPendingStreamRetry, + setSessionRuntimeStatus, + ]); // Re-roll the variant index on every hidden → visible transition. // Using a large random integer and letting the consumer mod by the @@ -397,13 +444,8 @@ export function usePlanningIndicator( }; }, [visible]); - // Slow-hint suppression is derived (not gated inside the timer effect) - // so that `useEffect` body stays pure — no synchronous setState inside - // an effect, no extra schedule/clear cycle when the chat tail flips - // between "settled assistant message" and "thinking again". return { count: visible ? 1 : 0, - showSlowHint: visible && showSlowHint && !lastIsSettledAssistantMessage, variantIndex, }; } diff --git a/src/engines/SessionCore/hooks/session/__tests__/launchPayload.test.ts b/src/engines/SessionCore/hooks/session/__tests__/launchPayload.test.ts index c1a91ccd8..b53fd747f 100644 --- a/src/engines/SessionCore/hooks/session/__tests__/launchPayload.test.ts +++ b/src/engines/SessionCore/hooks/session/__tests__/launchPayload.test.ts @@ -35,6 +35,48 @@ describe("launchPayload", () => { expect(session.repoPath).toBe("/workspace/repo-one"); }); + it("persists CLI agent type on the optimistic session row", () => { + const session = buildSessionFromLaunchResult({ + agentExecMode: "build", + effectiveSource: null, + isBackgroundLaunch: false, + result: { + sessionId: "cliagent-opencode", + category: DISPATCH_CATEGORY.CLI_AGENT, + name: "OpenCode session", + status: "running", + createdAt: "2026-01-01T00:00:00.000Z", + userInput: "hello", + background: false, + model: "minimax-m3", + cliAgentType: "opencode", + }, + }); + + expect(session.cliAgentType).toBe("opencode"); + }); + + it("falls back to the launch platform for the optimistic CLI session row", () => { + const session = buildSessionFromLaunchResult({ + agentExecMode: "build", + effectiveSource: null, + isBackgroundLaunch: false, + launchCliAgentType: "opencode", + result: { + sessionId: "cliagent-opencode", + category: DISPATCH_CATEGORY.CLI_AGENT, + name: "OpenCode session", + status: "running", + createdAt: "2026-01-01T00:00:00.000Z", + userInput: "hello", + background: false, + model: "minimax-m3", + }, + }); + + expect(session.cliAgentType).toBe("opencode"); + }); + it("hydrates optimistic session org context from launch readback", () => { const session = buildSessionFromLaunchResult({ agentExecMode: "build", @@ -105,6 +147,19 @@ describe("launchPayload", () => { expect(session.agentRole).toBe("custom"); }); + it("passes selected CLI agent type as the launch platform", () => { + const { launchParams } = buildSessionLaunchPayload({ + ...baseLaunchOptions(), + dispatchCategory: DISPATCH_CATEGORY.CLI_AGENT, + resolvedKeys: { + ...baseLaunchOptions().resolvedKeys, + cliAgentType: "opencode", + }, + }); + + expect(launchParams.platform).toBe("opencode"); + }); + it("passes non-primary multi-root folders as additional directories", () => { const { launchParams } = buildSessionLaunchPayload({ agentExecMode: "build", @@ -138,6 +193,7 @@ describe("launchPayload", () => { { path: "/workspace/repo-a" }, { path: "/workspace/repo-b" }, ], + worktreeLaunchSource: null, }); expect(launchParams.workspacePath).toBe("/workspace/repo-a"); @@ -201,6 +257,184 @@ describe("launchPayload", () => { } }); + it("omits worktree fields for a non-worktree (local) launch", () => { + const { launchParams } = buildSessionLaunchPayload({ + ...baseLaunchOptions(), + runningLocation: "local", + worktreeLaunchSource: { + kind: "branch", + label: "Branch: feature/x", + baseBranch: "feature/x", + sourceRef: "branch:feature/x", + }, + }); + + expect(launchParams.isolate).toBeUndefined(); + expect(launchParams.worktreePath).toBeUndefined(); + }); + + it("isolates from HEAD when no worktree source base branch is present", () => { + const { launchParams } = buildSessionLaunchPayload({ + ...baseLaunchOptions(), + runningLocation: "worktree", + worktreeLaunchSource: null, + }); + + expect(launchParams.isolate).toBe(true); + expect(launchParams.worktreePath).toBeUndefined(); + // No base branch on the source and none resolved → branch stays unset so + // the backend isolates from current HEAD. + expect(launchParams.branch).toBeUndefined(); + }); + + it("forwards the worktree source base branch as the isolate base ref", () => { + const { launchParams } = buildSessionLaunchPayload({ + ...baseLaunchOptions(), + runningLocation: "worktree", + worktreeLaunchSource: { + kind: "github", + label: "#42 Add caching", + baseBranch: "feature/add-caching", + sourceRef: "pr:42", + title: "Add caching", + }, + }); + + expect(launchParams.isolate).toBe(true); + expect(launchParams.branch).toBe("feature/add-caching"); + expect(launchParams.worktreePath).toBeUndefined(); + }); + + it("prefers the resolved base ref (PR head SHA) over the base branch label", () => { + const { launchParams } = buildSessionLaunchPayload({ + ...baseLaunchOptions(), + runningLocation: "worktree", + worktreeLaunchSource: { + kind: "github", + label: "#128 Fork feature", + baseBranch: "contributor:feature", + sourceRef: "pr:128", + title: "Fork feature", + resolvedBaseRef: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + branchNameOverride: "feature", + }, + }); + + expect(launchParams.isolate).toBe(true); + // Fork PR head branch is not a local ref — launch must use the fetched SHA. + expect(launchParams.branch).toBe( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ); + }); + + it("falls back to the base branch when no resolved base ref is present", () => { + const { launchParams } = buildSessionLaunchPayload({ + ...baseLaunchOptions(), + runningLocation: "worktree", + worktreeLaunchSource: { + kind: "github", + label: "#42 Same repo", + baseBranch: "feature/same-repo", + sourceRef: "pr:42", + }, + }); + + expect(launchParams.isolate).toBe(true); + expect(launchParams.branch).toBe("feature/same-repo"); + }); + + it("trims whitespace from the resolved base ref", () => { + const { launchParams } = buildSessionLaunchPayload({ + ...baseLaunchOptions(), + runningLocation: "worktree", + worktreeLaunchSource: { + kind: "github", + label: "#9 PR", + baseBranch: "feature/x", + sourceRef: "pr:9", + resolvedBaseRef: " bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb ", + }, + }); + + expect(launchParams.branch).toBe( + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ); + }); + + it("ignores a blank resolved base ref and falls back to base branch", () => { + const { launchParams } = buildSessionLaunchPayload({ + ...baseLaunchOptions(), + runningLocation: "worktree", + worktreeLaunchSource: { + kind: "github", + label: "#9 PR", + baseBranch: "feature/x", + sourceRef: "pr:9", + resolvedBaseRef: " ", + }, + }); + + expect(launchParams.branch).toBe("feature/x"); + }); + + it("trims whitespace from the worktree source base branch", () => { + const { launchParams } = buildSessionLaunchPayload({ + ...baseLaunchOptions(), + runningLocation: "worktree", + worktreeLaunchSource: { + kind: "branch", + label: "Branch: main", + baseBranch: " main ", + sourceRef: "branch:main", + }, + }); + + expect(launchParams.isolate).toBe(true); + expect(launchParams.branch).toBe("main"); + }); + + it("ignores a blank worktree source base branch and isolates from HEAD", () => { + const { launchParams } = buildSessionLaunchPayload({ + ...baseLaunchOptions(), + runningLocation: "worktree", + resolvedKeys: { + ...baseLaunchOptions().resolvedKeys, + branch: "develop", + }, + worktreeLaunchSource: { + kind: "name", + label: "Name: quick-fix", + baseBranch: " ", + sourceRef: "name:quick-fix", + }, + }); + + expect(launchParams.isolate).toBe(true); + // Blank source base branch → worktree fields do not override the + // resolved session branch. + expect(launchParams.branch).toBe("develop"); + }); + + it("reuses an existing worktree path and ignores source base branch", () => { + const { launchParams } = buildSessionLaunchPayload({ + ...baseLaunchOptions(), + runningLocation: "worktree", + selectedWorktreePath: "/worktrees/existing", + worktreeLaunchSource: { + kind: "github", + label: "#7 Fix bug", + baseBranch: "feature/fix-bug", + sourceRef: "pr:7", + }, + }); + + expect(launchParams.worktreePath).toBe("/worktrees/existing"); + expect(launchParams.isolate).toBeUndefined(); + // The existing worktree already carries its base ref; the source's base + // branch must not leak into the payload here. + expect(launchParams.branch).toBeUndefined(); + }); + it("does not block launched-session navigation on workspace-open side effects", () => { const launchHookPath = fileURLToPath( new URL( @@ -240,5 +474,6 @@ function baseLaunchOptions(): Parameters[0] { sessionName: "Test session", targetKind: SESSION_TARGET_KIND.AGENT, workspaceFolders: [], + worktreeLaunchSource: null, }; } diff --git a/src/engines/SessionCore/hooks/session/__tests__/useTodoSync.helpers.test.ts b/src/engines/SessionCore/hooks/session/__tests__/useTodoSync.helpers.test.ts index 636398f10..9be6e0268 100644 --- a/src/engines/SessionCore/hooks/session/__tests__/useTodoSync.helpers.test.ts +++ b/src/engines/SessionCore/hooks/session/__tests__/useTodoSync.helpers.test.ts @@ -16,17 +16,21 @@ */ import { describe, expect, it } from "vitest"; +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; +import type { TodoItem } from "@src/store/ui/todoAtom"; + import { isExpectedTodoLoadRejection, - normalizePersistedTodo, - normalizePersistedTodoList, sanitizeTodoDisplayText, } from "../todoNormalization"; - -// SessionEvent fixtures are not needed here — the `isManageTodoEvent` -// helper depends on the full hook module (which transitively -// imports atoms via jotai → localStorage). Coverage for that helper -// is provided by the broader `useTodoSync` integration tests. +import { + extractTodosFromManageTodoSequence, + findLatestManageTodoEvent, + isManageTodoEvent, + normalizePersistedTodo, + normalizePersistedTodoList, + serializeTodoSnapshot, +} from "../useTodoSync"; describe("normalizePersistedTodo", () => { it("uses the provided id when present", () => { @@ -239,3 +243,199 @@ describe("isExpectedTodoLoadRejection", () => { ); }); }); + +function makeManageTodoEvent( + overrides: Partial & Pick +): SessionEvent { + return { + chunk_id: overrides.id, + sessionId: "sdeagent-test", + createdAt: "2026-01-01T00:00:00.000Z", + functionName: "manage_todo", + uiCanonical: "manage_todo", + actionType: "tool_call", + args: {}, + result: {}, + source: "assistant", + displayText: "", + displayStatus: "completed", + displayVariant: "tool_call", + activityStatus: "processed", + ...overrides, + }; +} + +describe("isManageTodoEvent", () => { + it("matches manage_todo functionName", () => { + expect( + isManageTodoEvent( + makeManageTodoEvent({ id: "todo-1", functionName: "manage_todo" }) + ) + ).toBe(true); + }); + + it("returns false for unrelated events", () => { + expect( + isManageTodoEvent( + makeManageTodoEvent({ id: "read-1", functionName: "read_file" }) + ) + ).toBe(false); + }); +}); + +describe("findLatestManageTodoEvent", () => { + it("returns the latest manage_todo for the requested session", () => { + const events = [ + makeManageTodoEvent({ id: "todo-old" }), + makeManageTodoEvent({ id: "other-session", sessionId: "other" }), + makeManageTodoEvent({ id: "todo-new" }), + ]; + + expect(findLatestManageTodoEvent(events, "sdeagent-test")?.id).toBe( + "todo-new" + ); + }); + + it("respects replay maxIndex", () => { + const events = [ + makeManageTodoEvent({ id: "todo-old" }), + makeManageTodoEvent({ id: "todo-new" }), + ]; + + expect(findLatestManageTodoEvent(events, "sdeagent-test", 0)?.id).toBe( + "todo-old" + ); + }); +}); + +describe("extractTodosFromManageTodoSequence", () => { + function nativeTodoContent( + header: string, + todos: Array> + ): string { + return [ + header, + JSON.stringify(todos, null, 2), + "", + "Ensure that you continue to use the todo list to track your progress.", + ].join("\n"); + } + + it("backfills empty titles in later update snapshots from earlier todo events", () => { + const events = [ + makeManageTodoEvent({ + id: "todo-write", + result: { + content: nativeTodoContent("3 todos (3 remaining)", [ + { + index: 0, + content: "Review remaining version metadata changes", + status: "pending", + }, + { + index: 1, + content: "Verify synchronized release version", + status: "pending", + }, + { + index: 2, + content: "Commit synchronized release metadata", + status: "pending", + }, + ]), + }, + }), + makeManageTodoEvent({ + id: "todo-update", + result: { + content: nativeTodoContent( + "Updated todo #2 — 3 todos (1 remaining)", + [ + { index: 0, content: "", status: "completed" }, + { index: 1, content: " ", status: "completed" }, + { + index: 2, + content: "Verify commits and clean working tree", + status: "in_progress", + }, + ] + ), + }, + }), + ]; + + const todos = extractTodosFromManageTodoSequence(events, "sdeagent-test"); + + expect(todos.map((todo) => todo.content)).toEqual([ + "Review remaining version metadata changes", + "Verify synchronized release version", + "Verify commits and clean working tree", + ]); + expect(todos.map((todo) => todo.status)).toEqual([ + "completed", + "completed", + "in_progress", + ]); + }); +}); + +describe("serializeTodoSnapshot dedup", () => { + /** + * Mirrors the useTodoSync effect guard chain: + * 1. skip when extracted todos are empty (pre-merge tool_call) + * 2. skip when snapshot matches lastProcessedTodoSnapshotRef + * + * Regression for #247: event-id dedup blocked refresh when the same + * manage_todo tool_call id was merged with populated todos. + */ + function simulateTodoSyncDedup( + lastSnapshot: string | null, + todos: TodoItem[] + ): { lastSnapshot: string | null; applied: boolean } { + if (todos.length === 0) { + return { lastSnapshot, applied: false }; + } + const snapshot = serializeTodoSnapshot(todos); + if (snapshot === lastSnapshot) { + return { lastSnapshot, applied: false }; + } + return { lastSnapshot: snapshot, applied: true }; + } + + it("allows update when same event id transitions from empty to populated todos", () => { + let lastSnapshot: string | null = null; + + // Phase 1: tool_call arrives before tool_result merge — no todos yet. + const preMerge = simulateTodoSyncDedup(lastSnapshot, []); + expect(preMerge.applied).toBe(false); + expect(preMerge.lastSnapshot).toBeNull(); + lastSnapshot = preMerge.lastSnapshot; + + // Phase 2: same event id, now merged with populated todos. + const populatedTodos: TodoItem[] = [ + { id: "t1", content: "Implement feature", status: "pending" }, + { id: "t2", content: "Write tests", status: "in_progress" }, + ]; + const postMerge = simulateTodoSyncDedup(lastSnapshot, populatedTodos); + expect(postMerge.applied).toBe(true); + expect(postMerge.lastSnapshot).not.toBeNull(); + expect(postMerge.lastSnapshot).not.toBe(lastSnapshot); + lastSnapshot = postMerge.lastSnapshot; + + // Phase 3: identical re-process of merged event dedups on snapshot. + const replay = simulateTodoSyncDedup(lastSnapshot, populatedTodos); + expect(replay.applied).toBe(false); + expect(replay.lastSnapshot).toBe(lastSnapshot); + }); + + it("detects snapshot changes when todo status updates on same event", () => { + const base: TodoItem[] = [{ id: "t1", content: "Task", status: "pending" }]; + const updated: TodoItem[] = [ + { id: "t1", content: "Task", status: "completed" }, + ]; + + const before = serializeTodoSnapshot(base); + const after = serializeTodoSnapshot(updated); + expect(before).not.toBe(after); + }); +}); diff --git a/src/engines/SessionCore/hooks/session/useQueueDispatch.ts b/src/engines/SessionCore/hooks/session/useQueueDispatch.ts index 71e455985..273efa305 100644 --- a/src/engines/SessionCore/hooks/session/useQueueDispatch.ts +++ b/src/engines/SessionCore/hooks/session/useQueueDispatch.ts @@ -25,10 +25,7 @@ import type { Atom } from "jotai"; import { useStore } from "jotai"; import { useCallback, useEffect, useRef } from "react"; -import { - enterAgentOrgSessionIntervention, - getSession, -} from "@src/api/tauri/agent"; +import { getSession } from "@src/api/tauri/agent"; import { Message } from "@src/components/Message"; import type { AgentExecMode } from "@src/config/sessionCreatorConfig"; import { @@ -256,9 +253,6 @@ export function useQueueDispatch(): void { } ); await eventStoreProxy.append([userEvent], sessionId); - void enterAgentOrgSessionIntervention(sessionId).catch((error) => { - log.warn("[useQueueDispatch] intervention failed:", error); - }); // Pass displayContent as displayText when it differs from content // (i.e. skill pills were expanded) so the persisted event stores // the pill format and re-editing shows the pill, not the YAML. diff --git a/src/engines/SessionCore/hooks/session/useSessionCreator/useAdvancedConfig.ts b/src/engines/SessionCore/hooks/session/useSessionCreator/useAdvancedConfig.ts index 79b368573..58301082d 100644 --- a/src/engines/SessionCore/hooks/session/useSessionCreator/useAdvancedConfig.ts +++ b/src/engines/SessionCore/hooks/session/useSessionCreator/useAdvancedConfig.ts @@ -104,7 +104,7 @@ export function useAdvancedConfig(): UseAdvancedConfigResult { const selectedSourceModelType = lastModelSelection.selectedSourceModelType; const nativeHarnessType = dispatchCategory === "rust_agent" && - (selectedAccount?.canUseNativeHarness || + (selectedAccount?.nativeHarnessType || selectedSourceModelType === CLI_AGENT.CURSOR) ? (selectedAccount?.nativeHarnessType ?? NATIVE_HARNESS_TYPE.CURSOR) : undefined; diff --git a/src/engines/SessionCore/hooks/session/useSessionCreator/useSessionCreator.ts b/src/engines/SessionCore/hooks/session/useSessionCreator/useSessionCreator.ts index c87574d3c..8e9b0e6e2 100644 --- a/src/engines/SessionCore/hooks/session/useSessionCreator/useSessionCreator.ts +++ b/src/engines/SessionCore/hooks/session/useSessionCreator/useSessionCreator.ts @@ -71,6 +71,9 @@ export interface UseSessionCreatorOptions { workItemContext?: SessionLaunchWorkItemContext; resolveWorkItemContext?: () => Promise; onLaunchSuccess?: (info: SessionLaunchSuccessInfo) => void; + /** When true the selected CLI agent accepts an initial prompt via the GUI + * composer — canLaunch requires non-empty content (same as Rust agents). */ + cliAgentSupportsGui?: boolean; } export function useSessionCreator( @@ -86,6 +89,7 @@ export function useSessionCreator( workItemContext, resolveWorkItemContext, onLaunchSuccess, + cliAgentSupportsGui = false, } = options; // ============================================ // Refs @@ -412,7 +416,10 @@ export function useSessionCreator( const isContentEmpty = !editorContent || !editorContent.trim(); const canLaunch = useMemo(() => { - if (isContentEmpty) return false; + // Pure-TUI CLI agents launch without a prompt; GUI-capable CLI agents and + // all other categories require non-empty content. + const isCliTui = dispatchCategory === "cli_agent" && !cliAgentSupportsGui; + if (isContentEmpty && !isCliTui) return false; if ( dispatchCategory === "rust_agent" && !isOSMode && @@ -432,6 +439,7 @@ export function useSessionCreator( return hasModelOrAccount; }, [ isContentEmpty, + cliAgentSupportsGui, advancedConfig, dispatchCategory, effectiveSource, diff --git a/src/engines/SessionCore/hooks/session/useSessionCreator/useSessionLaunch/cursorIdeLaunch.ts b/src/engines/SessionCore/hooks/session/useSessionCreator/useSessionLaunch/cursorIdeLaunch.ts deleted file mode 100644 index c6293d11c..000000000 --- a/src/engines/SessionCore/hooks/session/useSessionCreator/useSessionLaunch/cursorIdeLaunch.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { - type CursorIdeControlNewComposerParams, - cursorBridgeNewComposer, -} from "@src/api/tauri/cursorBridge"; -import { promptRestartCursorWithDebugPort } from "@src/api/tauri/cursorBridge/restartDialog"; -import { DISPATCH_CATEGORY } from "@src/api/tauri/session"; -import type { Session } from "@src/store/session"; - -export interface BuildCursorComposerParamsOptions { - cursorCreatorModeOverride: string | null; - cursorCreatorModelOverride: string | null; - text: string; -} - -export function buildCursorComposerParams( - options: BuildCursorComposerParamsOptions -): CursorIdeControlNewComposerParams { - const { cursorCreatorModeOverride, cursorCreatorModelOverride, text } = - options; - - return { - text, - ...(cursorCreatorModelOverride - ? { modelName: cursorCreatorModelOverride } - : {}), - ...(cursorCreatorModeOverride ? { modeId: cursorCreatorModeOverride } : {}), - }; -} - -export async function openCursorComposerWithRetry( - params: CursorIdeControlNewComposerParams -): Promise>> { - try { - return await cursorBridgeNewComposer(params); - } catch (cursorError) { - const restarted = await promptRestartCursorWithDebugPort(cursorError); - if (!restarted) { - throw cursorError; - } - return cursorBridgeNewComposer(params); - } -} - -export function buildCursorIdeSession(options: { - composerId: string; - isBackgroundLaunch: boolean; - sessionName: string; - userInput: string; -}): Session { - const { composerId, isBackgroundLaunch, sessionName, userInput } = options; - const sessionId = `cursoride-${composerId}`; - const nowIso = new Date().toISOString(); - - return { - session_id: sessionId, - status: "running", - created_at: nowIso, - updated_at: nowIso, - created_time: nowIso, - updated_time: nowIso, - user_input: userInput, - repo_name: "", - name: sessionName || userInput.slice(0, 80) || "Cursor IDE", - branch: "", - is_active: !isBackgroundLaunch, - category: DISPATCH_CATEGORY.CURSOR_IDE, - agentIconId: "cursor", - }; -} diff --git a/src/engines/SessionCore/hooks/session/useSessionCreator/useSessionLaunch/index.tsx b/src/engines/SessionCore/hooks/session/useSessionCreator/useSessionLaunch/index.tsx index b6b808ceb..7a950eac7 100644 --- a/src/engines/SessionCore/hooks/session/useSessionCreator/useSessionLaunch/index.tsx +++ b/src/engines/SessionCore/hooks/session/useSessionCreator/useSessionLaunch/index.tsx @@ -11,7 +11,6 @@ import { useLocation, useNavigate } from "react-router-dom"; import { sessionLaunch } from "@src/api/tauri/agent/session"; import { DISPATCH_CATEGORY, KEY_SOURCE } from "@src/api/tauri/session"; -import { Message } from "@src/components/Message"; import { beginOptimisticTurn } from "@src/engines/SessionCore/control/optimisticTurnStatus"; import { markTurnRunning } from "@src/engines/SessionCore/control/turnLifecycle"; import { @@ -19,7 +18,9 @@ import { pendingSyntheticEventAtom, } from "@src/engines/SessionCore/core/atoms"; import { SESSION_CREATOR_LAUNCH_MODE } from "@src/features/SessionCreator/types"; +import { autoTagLaunchedSessionToActiveCloudOrg } from "@src/features/TeamCollaboration/autoTagNewSession"; import { createLogger } from "@src/hooks/logger"; +import { useSecretScanGuard } from "@src/hooks/security/useSecretScanGuard"; import { collectAdeContext } from "@src/services/context/collectors"; import { activeSessionIdAtom, @@ -35,10 +36,9 @@ import { } from "@src/store/session"; import { lastUserMessageAtom } from "@src/store/session/cliSessionStatusAtom"; import { creatorDefaultExecModeAtom } from "@src/store/session/creatorDefaultExecModeAtom"; -import { cursorCreatorModeOverrideAtom } from "@src/store/session/cursorModeOverrideAtom"; -import { cursorCreatorModelOverrideAtom } from "@src/store/session/cursorModelOverrideAtom"; import { runningLocationAtom } from "@src/store/session/runningLocationAtom"; import { selectedWorktreePathAtom } from "@src/store/session/selectedWorktreePathAtom"; +import { worktreeLaunchSourceAtom } from "@src/store/session/worktreeLaunchSourceAtom"; import { stationModeAtom } from "@src/store/ui/simulatorAtom"; import { triggerSessionExpired } from "@src/store/ui/uiAtom"; import type { ViewModeType } from "@src/store/ui/viewModeAtom"; @@ -49,12 +49,6 @@ import { import { workspaceFoldersAtom } from "@src/store/ui/workspaceFoldersAtom"; import { emitOpenWorkspace } from "@src/util/ui/window/windowManager"; -import { - buildCursorComposerParams, - buildCursorIdeSession, - openCursorComposerWithRetry, -} from "./cursorIdeLaunch"; -import { formatAgentLaunchError } from "./errorUtils"; import { prepareLaunchInput } from "./inputPreparation"; import { handleNonCursorLaunchError } from "./launchErrorHandling"; import { handleSessionNavigation } from "./launchHelpers"; @@ -96,6 +90,7 @@ export function useSessionLaunch( } = options; const { t } = useTranslation("sessions"); + const guardAgainstSecrets = useSecretScanGuard(); const [isLoading, setIsLoading] = useState(false); const { closeAddFundsModal, @@ -114,16 +109,7 @@ export function useSessionLaunch( const agentExecMode = useAtomValue(creatorDefaultExecModeAtom); const runningLocation = useAtomValue(runningLocationAtom); const selectedWorktreePath = useAtomValue(selectedWorktreePathAtom); - const cursorCreatorModelOverride = useAtomValue( - cursorCreatorModelOverrideAtom - ); - const setCursorCreatorModelOverride = useSetAtom( - cursorCreatorModelOverrideAtom - ); - const cursorCreatorModeOverride = useAtomValue(cursorCreatorModeOverrideAtom); - const setCursorCreatorModeOverride = useSetAtom( - cursorCreatorModeOverrideAtom - ); + const worktreeLaunchSource = useAtomValue(worktreeLaunchSourceAtom); const workspaceFolders = useAtomValue(workspaceFoldersAtom); const setViewMode = useSetAtom(viewModeAtom); const setIsSwitching = useSetAtom(viewModeSwitchingAtom); @@ -170,76 +156,6 @@ export function useSessionLaunch( ] ); - const executeCursorIdeLaunch = useCallback( - async ( - agentInput: string, - userInput: string, - isBackgroundLaunch: boolean, - launchWorkItemContext?: typeof workItemContext - ) => { - const result = await openCursorComposerWithRetry( - buildCursorComposerParams({ - text: agentInput, - cursorCreatorModelOverride, - cursorCreatorModeOverride, - }) - ); - const session = buildCursorIdeSession({ - composerId: result.composerId, - isBackgroundLaunch, - sessionName, - userInput, - }); - upsertSession(session); - - injectSyntheticUserEventIfNeeded({ - dispatchLoadSession, - hasImages: false, - imageDataUrls: undefined, - isBackgroundLaunch, - isContentEmpty, - sessionId: session.session_id, - setLastUserMessage, - setPendingSyntheticEvent, - userInput, - }); - - if (imageDataUrls && imageDataUrls.length > 0) { - clearImages?.(); - } - - if (isBackgroundLaunch) { - clearDraft(null); - onLaunchSuccess?.({ - sessionId: session.session_id, - workItemContext: launchWorkItemContext, - }); - } else { - navigateToLaunchedSession(session.session_id, false); - } - setSessionSource(null); - setCursorCreatorModelOverride(null); - setCursorCreatorModeOverride(null); - }, - [ - clearDraft, - clearImages, - cursorCreatorModeOverride, - cursorCreatorModelOverride, - dispatchLoadSession, - imageDataUrls, - isContentEmpty, - navigateToLaunchedSession, - onLaunchSuccess, - sessionName, - setCursorCreatorModeOverride, - setCursorCreatorModelOverride, - setLastUserMessage, - setPendingSyntheticEvent, - setSessionSource, - ] - ); - const handleLaunch = useCallback(async () => { if (isLoading) return false; @@ -255,6 +171,9 @@ export function useSessionLaunch( ); if (!confirmedShortInput) return false; + const clearedSecretScan = await guardAgainstSecrets(editorContent); + if (!clearedSecretScan) return false; + const { agentInput, userInput } = await prepareLaunchInput({ editorContent, effectiveSource, @@ -263,36 +182,6 @@ export function useSessionLaunch( const isBackgroundLaunch = isBackgroundLaunchMode(launchMode); - if (dispatchCategory === DISPATCH_CATEGORY.CURSOR_IDE) { - setIsLoading(true); - try { - const resolvedWorkItemContext = resolveWorkItemContext - ? await resolveWorkItemContext() - : workItemContext; - if (resolveWorkItemContext && !resolvedWorkItemContext) return false; - - await executeCursorIdeLaunch( - agentInput, - userInput, - isBackgroundLaunch, - resolvedWorkItemContext ?? undefined - ); - return true; - } catch (error) { - log.error("Error creating Cursor IDE session:", error); - Message.error( - formatAgentLaunchError( - error instanceof Error - ? error.message - : "An unexpected error occurred" - ) - ); - return false; - } finally { - setIsLoading(false); - } - } - setIsLoading(true); try { @@ -332,6 +221,7 @@ export function useSessionLaunch( sessionName, targetKind, workspaceFolders, + worktreeLaunchSource, }); const result = await sessionLaunch({ @@ -359,10 +249,18 @@ export function useSessionLaunch( agentExecMode, effectiveSource, isBackgroundLaunch, + launchCliAgentType: launchParams.platform, launchOrgContext: resolvedWorkItemContext ?? undefined, result, }) ); + void autoTagLaunchedSessionToActiveCloudOrg({ + sessionId: result.sessionId, + repoPath: effectiveSource?.repoPath ?? null, + launchOrgId: resolvedWorkItemContext?.orgId ?? null, + }).catch((error: unknown) => { + log.warn("Failed to auto-tag launched session to cloud org", error); + }); if (selectedAgentOrgId) { void loadSidebarSessions({ forceRefresh: true }).catch( (error: unknown) => { @@ -443,11 +341,11 @@ export function useSessionLaunch( validateSessionConfig, editorContent, t, + guardAgainstSecrets, effectiveSource, composerInputRef, launchMode, dispatchCategory, - executeCursorIdeLaunch, advancedConfig, clearDraft, showAuthError, @@ -461,6 +359,7 @@ export function useSessionLaunch( sessionName, targetKind, workspaceFolders, + worktreeLaunchSource, clearImages, dispatchLoadSession, setLastUserMessage, diff --git a/src/engines/SessionCore/hooks/session/useSessionCreator/useSessionLaunch/launchHelpers.ts b/src/engines/SessionCore/hooks/session/useSessionCreator/useSessionLaunch/launchHelpers.ts index dc0d55aa0..a2fda51ec 100644 --- a/src/engines/SessionCore/hooks/session/useSessionCreator/useSessionLaunch/launchHelpers.ts +++ b/src/engines/SessionCore/hooks/session/useSessionCreator/useSessionLaunch/launchHelpers.ts @@ -20,7 +20,7 @@ export { createSyntheticUserEvent } from "@src/engines/SessionCore/sync/adapters * View modes whose session creators are allowed to launch a session * "in place" — i.e. without navigating the user back to WorkStation. * - * `workStation` itself is the canonical case. Ops Control now lives in + * `workStation` itself is the canonical case. Kanban now lives in * Home, so launches from that page use the standard WorkStation navigation. * * Anything not in this set falls back to the legacy "navigate to diff --git a/src/engines/SessionCore/hooks/session/useSessionCreator/useSessionLaunch/launchPayload.ts b/src/engines/SessionCore/hooks/session/useSessionCreator/useSessionLaunch/launchPayload.ts index 7fd6e1ee5..ade4f90f7 100644 --- a/src/engines/SessionCore/hooks/session/useSessionCreator/useSessionLaunch/launchPayload.ts +++ b/src/engines/SessionCore/hooks/session/useSessionCreator/useSessionLaunch/launchPayload.ts @@ -24,6 +24,7 @@ import type { SessionLaunchOrgContext, SessionSource, } from "@src/store/session/creatorStateAtom"; +import type { WorktreeLaunchSource } from "@src/store/session/worktreeLaunchSourceAtom"; import type { ResolvedKeys } from "./resolveKeys"; @@ -48,6 +49,7 @@ export interface BuildSessionLaunchParamsOptions { sessionName: string; targetKind: SessionTargetKind; workspaceFolders: WorkspaceFolderRef[]; + worktreeLaunchSource: WorktreeLaunchSource | null; } interface BuildLaunchPayloadResult { @@ -148,18 +150,54 @@ function getRustAgentIdentityFields(options: { return {}; } -function getWorktreeFields(options: { +/** + * Resolve the worktree-related launch fields. + * + * Three shapes come out of here, matching the backend's three worktree modes + * (`launch_rust_agent` in `state/commands/session/launch.rs`): + * + * - Not a worktree launch → `{}` (plain local workspace). + * - Reusing an existing worktree path → `{ worktreePath }`. The base ref is + * already baked into that checkout, so the picked source metadata is moot. + * - Fresh isolated worktree → `{ isolate: true }`, plus `branch` when the + * picked source carries a base ref. The backend's + * `create_session_worktree` runs `git worktree add -b agent/ + * `, so `branch` is literally the git base ref the isolated + * worktree is created from. Forwarding the picked source's base ref here + * makes the isolated worktree track the chosen PR head / branch / smart + * base explicitly, independent of whatever the branch selector last held. + * + * `resolvedBaseRef` wins over `baseBranch` when present: it is the concrete + * commit-ish (PR head SHA) that `worktree_resolve_pr_base` fetched, which is + * what lets fork / cross-repo PRs — whose head branch is not a local ref — + * actually drive worktree creation. `baseBranch` remains the fallback for + * branch / smart / same-repo sources that need no fetch. + * + * `sourceRef` / `kind` / PR number stay synthetic identifiers with no backend + * field, so they cannot influence worktree creation and are not forwarded. + */ +export function getWorktreeFields(options: { runningLocation: RunningLocation; selectedWorktreePath: string | null; + worktreeLaunchSource: WorktreeLaunchSource | null; }): Partial { - const { runningLocation, selectedWorktreePath } = options; + const { runningLocation, selectedWorktreePath, worktreeLaunchSource } = + options; if (runningLocation !== "worktree") { return {}; } - return selectedWorktreePath - ? { worktreePath: selectedWorktreePath } - : { isolate: true }; + if (selectedWorktreePath) { + return { worktreePath: selectedWorktreePath }; + } + + const base = + worktreeLaunchSource?.resolvedBaseRef?.trim() || + worktreeLaunchSource?.baseBranch?.trim(); + return { + isolate: true, + ...(base ? { branch: base } : {}), + }; } export function buildSessionLaunchPayload( @@ -182,6 +220,7 @@ export function buildSessionLaunchPayload( sessionName, targetKind, workspaceFolders, + worktreeLaunchSource, } = options; const sessionRepoPath = effectiveSource?.repoPath ?? ""; @@ -231,7 +270,11 @@ export function buildSessionLaunchPayload( ...(isRustAgent && resolvedKeys.nativeHarnessType ? { nativeHarnessType: resolvedKeys.nativeHarnessType } : {}), - ...getWorktreeFields({ runningLocation, selectedWorktreePath }), + ...getWorktreeFields({ + runningLocation, + selectedWorktreePath, + worktreeLaunchSource, + }), ...(additionalDirectories.length > 0 ? { additionalDirectories } : {}), }; @@ -248,6 +291,7 @@ export function buildSessionFromLaunchResult(options: { agentExecMode: AgentExecMode; effectiveSource: SessionSource | null; isBackgroundLaunch: boolean; + launchCliAgentType?: SessionLaunchResult["cliAgentType"]; launchOrgContext?: Partial; result: SessionLaunchResult; }): Session { @@ -255,6 +299,7 @@ export function buildSessionFromLaunchResult(options: { agentExecMode, effectiveSource, isBackgroundLaunch, + launchCliAgentType, launchOrgContext, result, } = options; @@ -273,6 +318,7 @@ export function buildSessionFromLaunchResult(options: { | typeof DISPATCH_CATEGORY.RUST_AGENT | typeof DISPATCH_CATEGORY.CLI_AGENT, model: result.model ?? undefined, + cliAgentType: result.cliAgentType ?? launchCliAgentType ?? undefined, agentExecMode, ...(result.agentOrgId ? { agentIconId: AGENT_ORG_ICON_ID, agentOrgId: result.agentOrgId } diff --git a/src/engines/SessionCore/hooks/session/useSessionDiscovery.ts b/src/engines/SessionCore/hooks/session/useSessionDiscovery.ts index e0c0fe0fb..a39c3d206 100644 --- a/src/engines/SessionCore/hooks/session/useSessionDiscovery.ts +++ b/src/engines/SessionCore/hooks/session/useSessionDiscovery.ts @@ -19,6 +19,7 @@ import type { AvailableApiProvider, KeyInfo, } from "@src/api/tauri/rpc/schemas/validation"; +import { loadSharedLocalKeys } from "@src/hooks/keyVault/sharedLocalKeyStore"; import { createLogger } from "@src/hooks/logger"; import { agentRegistryAtom } from "@src/store/session/agentRegistryAtom"; @@ -212,7 +213,7 @@ export function useSessionDiscovery( const [apiProviders, rawAgents, allKeys] = await Promise.all([ rpc.validation.getAvailableApiProviders(), rpc.validation.getAvailableAgents(), - rpc.validation.listKeys(), + loadSharedLocalKeys(), ]); if (!mountedRef.current) return; diff --git a/src/engines/SessionCore/hooks/session/useTodoSync.ts b/src/engines/SessionCore/hooks/session/useTodoSync.ts index a1c15c65e..5098df288 100644 --- a/src/engines/SessionCore/hooks/session/useTodoSync.ts +++ b/src/engines/SessionCore/hooks/session/useTodoSync.ts @@ -2,24 +2,25 @@ * useTodoSync Hook * * Syncs manage_todo events from session updates into the per-session - * todo slot. Two data paths: + * todo slot. Three data paths: * * 1. **Cold-start / session switch**: `getTodos(sessionId)` fetches * persisted todos from the Rust SQLite backend. - * 2. **Simulator events (replay)**: scans `simulatorEventsAtom` for - * the latest `manage_todo` tool-call event up to the current - * replay cursor. + * 2. **Event store (live + replay)**: scans session events for the + * latest `manage_todo` tool event up to the current replay cursor. + * 3. **IPC push (live)**: `agent:todos_updated` via + * `handleTodosUpdated` (eventHandlers/agentSpecific.ts). * - * Real-time push from `agent:todos_updated` is handled upstream in - * `handleTodosUpdated` (eventHandlers/agentSpecific.ts), which writes - * directly to `updateTodosForSessionAtom` via the Jotai store — no - * browser CustomEvent relay needed. + * Mounted from `ChatView` so the sticky pin bar stays aligned with chat + * blocks even when the IPC push is missed. */ import { useAtomValue, useSetAtom, useStore } from "jotai"; import { useEffect, useRef } from "react"; import { getTodos } from "@src/api/tauri/agent"; import { currentEventAtom } from "@src/engines/SessionCore/core/atoms"; +import { eventsAtom } from "@src/engines/SessionCore/core/atoms/events"; +import { sessionIdAtom } from "@src/engines/SessionCore/core/atoms/metadata"; import type { SessionEvent } from "@src/engines/SessionCore/core/types"; import { simulatorEventsAtom } from "@src/engines/SessionCore/derived/simulatorEvents"; import { extractTodoData } from "@src/engines/SessionCore/rendering/props"; @@ -33,6 +34,7 @@ import { sessionTodoMapAtom, updateTodosForSessionAtom, } from "@src/store/ui/todoAtom"; +import { preserveTodoContent } from "@src/store/ui/todoMerge"; import { type RawPersistedTodoItem, @@ -69,6 +71,38 @@ export type { RawPersistedTodoItem }; export const normalizePersistedTodo = normalizePersistedTodoCore; export const normalizePersistedTodoList = normalizePersistedTodoListCore; +function eventMatchesSession(event: SessionEvent, sessionId: string): boolean { + const eventSid = event.sessionId; + return !eventSid || eventSid === sessionId; +} + +export function findLatestManageTodoEvent( + events: readonly SessionEvent[], + sessionId: string, + maxIndex = events.length - 1 +): SessionEvent | null { + const limit = Math.min(maxIndex, events.length - 1); + for (let index = limit; index >= 0; index--) { + const event = events[index]; + if (!isManageTodoEvent(event)) continue; + if (!eventMatchesSession(event, sessionId)) continue; + return event; + } + return null; +} + +export function serializeTodoSnapshot(todos: TodoItem[]): string { + return JSON.stringify( + todos.map((todo) => ({ + id: todo.id, + content: todo.content, + activeForm: todo.activeForm, + status: todo.status, + blockedBy: todo.blockedBy, + })) + ); +} + function extractTodosFromEvent(event: SessionEvent): TodoItem[] { const normalized = normalizeActivity( event as unknown as Record @@ -84,7 +118,7 @@ function extractTodosFromEvent(event: SessionEvent): TodoItem[] { context: "chat" as const, }); - return todoData.todos.map((todo) => { + return todoData.todos.map((todo, idx) => { const raw = todo as unknown as Record; const activeForm = typeof raw.activeForm === "string" && raw.activeForm.length > 0 @@ -94,7 +128,7 @@ function extractTodosFromEvent(event: SessionEvent): TodoItem[] { ? (raw.blockedBy as number[]) : todo.blockedBy; return { - id: todo.id || crypto.randomUUID(), + id: todo.id || `event-todo-${idx}`, content: sanitizeTodoDisplayText(todo.content || ""), activeForm: activeForm ? sanitizeTodoDisplayText(activeForm) : undefined, status: (todo.status || "pending") as TodoItem["status"], @@ -103,12 +137,35 @@ function extractTodosFromEvent(event: SessionEvent): TodoItem[] { }); } +export function extractTodosFromManageTodoSequence( + events: readonly SessionEvent[], + sessionId: string, + maxIndex = events.length - 1 +): TodoItem[] { + const limit = Math.min(maxIndex, events.length - 1); + let todos: TodoItem[] = []; + + for (let index = 0; index <= limit; index++) { + const event = events[index]; + if (!isManageTodoEvent(event)) continue; + if (!eventMatchesSession(event, sessionId)) continue; + + const nextTodos = extractTodosFromEvent(event); + if (nextTodos.length === 0) continue; + todos = preserveTodoContent(todos, nextTodos); + } + + return todos; +} + // ============================================ // Hook // ============================================ export function useTodoSync(sessionId?: string): void { const simulatorEvents = useAtomValue(simulatorEventsAtom); + const liveEvents = useAtomValue(eventsAtom); + const pipelineSessionId = useAtomValue(sessionIdAtom); const currentEvent = useAtomValue(currentEventAtom); const updateTodosForSession = useSetAtom(updateTodosForSessionAtom); const clearTodosForSession = useSetAtom(clearTodosForSessionAtom); @@ -116,7 +173,7 @@ export function useTodoSync(sessionId?: string): void { const lastSessionIdRef = useRef(sessionId); const processedCountRef = useRef(0); - const lastProcessedTodoIdRef = useRef(null); + const lastProcessedTodoSnapshotRef = useRef(null); const lastCurrentEventIdRef = useRef(null); // Clear todos on session change, then load persisted todos from backend @@ -125,7 +182,7 @@ export function useTodoSync(sessionId?: string): void { const prev = lastSessionIdRef.current; lastSessionIdRef.current = sessionId; processedCountRef.current = 0; - lastProcessedTodoIdRef.current = null; + lastProcessedTodoSnapshotRef.current = null; // Only clear when actually switching to a *different* session. // A transient undefined (panel remount / layout shuffle) must not // wipe the live slot — that caused the todo pill to flash 0 and @@ -179,23 +236,31 @@ export function useTodoSync(sessionId?: string): void { }; }, [sessionId, clearTodosForSession, updateTodosForSession, store]); - // Process todo events — find the LATEST manage_todo event up to current replay position + // Process todo events — find the latest manage_todo up to the replay cursor. + // Prefer the full event store when it matches this surface's session so + // merged tool_result payloads refresh the pin bar on the same tool_call id. useEffect(() => { if (!sessionId) return; - if (!simulatorEvents || simulatorEvents.length === 0) return; + if (pipelineSessionId && pipelineSessionId !== sessionId) return; + + const replayEvents = + pipelineSessionId === sessionId && liveEvents.length > 0 + ? liveEvents + : simulatorEvents; + if (!replayEvents || replayEvents.length === 0) return; const currentEventId = currentEvent?.id ?? null; if ( - simulatorEvents.length === processedCountRef.current && + replayEvents.length === processedCountRef.current && currentEventId === lastCurrentEventIdRef.current ) { return; } - let maxIndex = simulatorEvents.length - 1; + let maxIndex = replayEvents.length - 1; if (currentEventId) { - const currentIndex = simulatorEvents.findIndex( + const currentIndex = replayEvents.findIndex( (event) => event.id === currentEventId ); if (currentIndex !== -1) { @@ -203,38 +268,43 @@ export function useTodoSync(sessionId?: string): void { } } - let latestTodoEvent: SessionEvent | null = null; - for (let index = maxIndex; index >= 0; index--) { - const event = simulatorEvents[index]; - if (isManageTodoEvent(event)) { - // Skip events that belong to a different session (e.g. a subagent - // whose manage_todo leaked into the parent's event stream). - const eventSid = event.sessionId; - if (eventSid && eventSid !== sessionId) continue; - latestTodoEvent = event; - break; - } - } + const latestTodoEvent = findLatestManageTodoEvent( + replayEvents, + sessionId, + maxIndex + ); - processedCountRef.current = simulatorEvents.length; + processedCountRef.current = replayEvents.length; lastCurrentEventIdRef.current = currentEventId; if (!latestTodoEvent) return; - if (latestTodoEvent.id === lastProcessedTodoIdRef.current) return; - const todos = extractTodosFromEvent(latestTodoEvent); + const todos = extractTodosFromManageTodoSequence( + replayEvents, + sessionId, + maxIndex + ); + if (todos.length === 0) return; - if (todos.length > 0) { - updateTodosForSession({ - sessionId, - todos, - merge: false, - timestamp: latestTodoEvent.createdAt, - }); - } + const snapshot = serializeTodoSnapshot(todos); + if (snapshot === lastProcessedTodoSnapshotRef.current) return; + + updateTodosForSession({ + sessionId, + todos, + merge: false, + timestamp: latestTodoEvent.createdAt, + }); - lastProcessedTodoIdRef.current = latestTodoEvent.id; - }, [sessionId, simulatorEvents, currentEvent, updateTodosForSession]); + lastProcessedTodoSnapshotRef.current = snapshot; + }, [ + sessionId, + pipelineSessionId, + liveEvents, + simulatorEvents, + currentEvent, + updateTodosForSession, + ]); } export default useTodoSync; diff --git a/src/engines/SessionCore/hooks/useAgentADEActions.ts b/src/engines/SessionCore/hooks/useAgentADEActions.ts index f6b268003..14504fe1e 100644 --- a/src/engines/SessionCore/hooks/useAgentADEActions.ts +++ b/src/engines/SessionCore/hooks/useAgentADEActions.ts @@ -47,6 +47,11 @@ import { adeManagerEnabledAtom } from "@src/store/ui/uiAtom"; import { activeWorkspaceRootAtom } from "@src/store/workspace"; import { getInstrumentedStore } from "@src/util/core/state/instrumentedStore"; +import { + extractInvokingSessionId, + resolveTrustedDispatchParams, +} from "./adeReplyBinding"; + /** * Pending session proposal — set by `session.propose` handler, * consumed by `AdeAwareSessionCreatorSlot` in AppLayout when the @@ -82,6 +87,7 @@ interface AdeActionDetail { params: Record; operation?: AdeActionOperation; sessionId?: string; + invokingSessionId?: string; } function isRecord(value: unknown): value is Record { @@ -103,6 +109,7 @@ function parseAdeActionEnvelope(rawMessage: string): AdeActionDetail | null { const action = payload.action; const params = payload.params; const sessionId = payload.sessionId; + const invokingSessionId = extractInvokingSessionId(payload); return { correlationId, @@ -114,6 +121,7 @@ function parseAdeActionEnvelope(rawMessage: string): AdeActionDetail | null { ...(typeof action === "string" ? { action } : {}), params: isRecord(params) ? params : {}, ...(typeof sessionId === "string" ? { sessionId } : {}), + ...(invokingSessionId !== undefined ? { invokingSessionId } : {}), }; } @@ -442,7 +450,12 @@ export function useAgentADEActions(): void { return; } - const result = await zodActionRegistry.execute(action, params); + const dispatchParams = resolveTrustedDispatchParams( + action, + params, + detail.invokingSessionId + ); + const result = await zodActionRegistry.execute(action, dispatchParams); await sendAdeActionResult(correlationId, { success: result.success, diff --git a/src/engines/SessionCore/ingestion/agentMessageAdapters.ts b/src/engines/SessionCore/ingestion/agentMessageAdapters.ts index 7df211f05..6c2b99d13 100644 --- a/src/engines/SessionCore/ingestion/agentMessageAdapters.ts +++ b/src/engines/SessionCore/ingestion/agentMessageAdapters.ts @@ -52,6 +52,14 @@ export interface PersistedMessage { sequence: number; createdAt: string; images: string | null; + /** + * Set on compact-boundary rows: sequence number the preserved tail starts + * at. Serialized by Rust's `AgentMessageRow` (serde camelCase); non-null + * marks the row as a compaction summary rather than a normal message. + */ + compactFromSequence?: number | null; + compactTokensBefore?: number | null; + compactTokensAfter?: number | null; } // ============================================ @@ -110,6 +118,130 @@ export function buildResult(msg: AgentMessageBase): Record { } } +// ============================================ +// Compact boundary parsing +// ============================================ + +/** + * Continuation instruction the Rust compactor appends to every compaction + * summary. Mirrors `COMPACT_CONTINUATION_SUFFIX` in + * `src-tauri/crates/agent-core/src/core/model_context/compaction.rs` — keep + * the literals in sync. Stripped from the summary before display: it is + * model-facing steering, not user-facing content. + */ +export const COMPACT_CONTINUATION_SUFFIX = + "This session is being continued from an earlier conversation that exceeded the context window; the older messages were compacted into the summary above. Resume the work directly from this state — do not acknowledge this summary, do not re-describe it to the user, and do not ask questions it already answers. Continue with the last task you were working on, following the user's most recent instructions."; + +/** + * Header prefixes written by the Rust compactors (LLM compact and Session + * Memory compact). Mirror `LLM_COMPACT_BOUNDARY_PREFIX` / + * `SM_COMPACT_BOUNDARY_PREFIX` in + * `src-tauri/crates/agent-core/src/core/model_context/session_memory/compact.rs` + * (prefix match stops before the dash so both the em-dash and the legacy + * hyphen "compacted without summary" marker are covered). + */ +const COMPACT_BOUNDARY_PREFIXES = [ + "[Conversation summary ", + "[Session Memory ", +] as const; + +export interface ParsedCompactBoundary { + /** Header line, e.g. `[Conversation summary — 12 earlier messages compacted]`. */ + header: string | null; + /** Summary body with the header line and continuation suffix stripped. */ + body: string; + /** `N` parsed from the header, when present. */ + compactedCount: number | null; +} + +/** True when a persisted row's content carries a compact-boundary header. */ +export function isCompactBoundaryContent(content: string): boolean { + return COMPACT_BOUNDARY_PREFIXES.some((prefix) => content.startsWith(prefix)); +} + +/** + * Split a persisted compact-boundary row into header line and summary body, + * stripping the model-facing continuation instructions. + */ +export function parseCompactBoundaryContent( + content: string +): ParsedCompactBoundary { + let text = content; + const suffixIndex = text.indexOf(COMPACT_CONTINUATION_SUFFIX); + if (suffixIndex !== -1) { + text = text.slice(0, suffixIndex).trimEnd(); + } + + let header: string | null = null; + let body = text.trim(); + if (isCompactBoundaryContent(text)) { + const newlineIndex = text.indexOf("\n"); + if (newlineIndex === -1) { + header = text.trim(); + body = ""; + } else { + header = text.slice(0, newlineIndex).trim(); + body = text.slice(newlineIndex + 1).trim(); + } + } + + const countMatch = header?.match(/(\d+)\s+earlier messages compacted\]/); + const compactedCount = countMatch ? Number(countMatch[1]) : null; + + return { header, body, compactedCount }; +} + +/** + * Convert a compact-boundary row into a SessionEvent routed to the dedicated + * `context_compacted` renderer. + * + * Mirrors `makeRateLimitHintEvent` (shared/eventFactories.ts): neutral + * `actionType`/`source` keep the row out of the assistant-prose heuristics in + * ActivityRouter/willEventRenderContent, and `displayVariant` stays a + * wire-safe value — SessionEvents round-trip through Rust + * (`es_merge_tool_results`, event store), whose `EventDisplayVariant` serde + * enum rejects unknown variants. Component routing happens via `uiCanonical`. + */ +export function compactBoundaryToSessionEvent( + msg: PersistedMessage, + sessionId: string +): SessionEvent { + const { header, body, compactedCount } = parseCompactBoundaryContent( + msg.content + ); + const functionName = "context_compacted"; + return { + id: msg.id, + chunk_id: msg.id, + sessionId, + createdAt: msg.createdAt, + functionName, + uiCanonical: normalizeFunctionName(functionName), + actionType: "system", + args: {}, + result: { + observation: body, + ...(header !== null ? { header } : {}), + ...(compactedCount !== null ? { compactedCount } : {}), + ...(msg.compactTokensBefore != null && msg.compactTokensAfter != null + ? { + tokensBefore: msg.compactTokensBefore, + tokensAfter: msg.compactTokensAfter, + } + : {}), + }, + source: "system", + // Fall back to the header line when the summary body is empty (legacy + // "compacted without summary" markers) so willEventRenderContent still + // lets the collapsed marker through. + displayText: body || (header ?? ""), + displayStatus: "completed", + displayVariant: "message", + activityStatus: "agent", + isDelta: false, + }; +} + /** * Convert persisted DB messages to SessionEvents. * @@ -122,6 +254,17 @@ export function persistedMessageToSessionEvent( transformDisplayText?: (content: string, source: string) => string; } ): SessionEvent { + // Compact-boundary rows get a dedicated collapsed renderer instead of + // rendering the raw summary + continuation instructions as assistant + // prose. `compactFromSequence` is authoritative; the content-prefix check + // covers rows persisted before the column existed. + if ( + msg.compactFromSequence != null || + (msg.role === "system" && isCompactBoundaryContent(msg.content)) + ) { + return compactBoundaryToSessionEvent(msg, sessionId); + } + const actionType = msg.role === "tool_call" ? "tool_call" diff --git a/src/engines/SessionCore/rendering/orgTaskOutcome.test.ts b/src/engines/SessionCore/rendering/orgTaskOutcome.test.ts new file mode 100644 index 000000000..86ddf2990 --- /dev/null +++ b/src/engines/SessionCore/rendering/orgTaskOutcome.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "vitest"; + +import type { RustExtractedOrgTaskData } from "@src/engines/SessionCore/core/types"; + +import { + isPersistedOrgTaskEvent, + resolveOrgTaskOperationOutcome, +} from "./orgTaskOutcome"; + +function legacyTask( + overrides: Partial = {} +): RustExtractedOrgTaskData { + return { + action: "create", + outcome: "unknown", + tasks: [], + ...overrides, + }; +} + +describe("resolveOrgTaskOperationOutcome", () => { + it("preserves a new explicit outcome", () => { + expect( + resolveOrgTaskOperationOutcome({ + ...legacyTask(), + outcome: "rejected", + }) + ).toBe("rejected"); + }); + + it.each([ + { rejected: true, rejection_code: "lifecycle_owner_only" }, + { created: false }, + { requires_dependency_confirmation: true }, + { authorization_denied: true }, + { already_exists: true }, + { status_ignored: true }, + { deleted: false }, + ])("treats a legacy structured non-mutation as rejected: %o", (result) => { + expect(resolveOrgTaskOperationOutcome(legacyTask(), result)).toBe( + "rejected" + ); + }); + + it("recovers an omitted legacy outcome from a wrapped result", () => { + const extracted = { ...legacyTask(), outcome: undefined }; + + expect( + resolveOrgTaskOperationOutcome(extracted, { + content: JSON.stringify({ authorization_denied: true }), + }) + ).toBe("rejected"); + }); + + it("downgrades legacy task validation failures to a correction state", () => { + const result = { + content: + "Error executing task_update: Invalid parameters: parameter validation failed: missing field `summary`", + }; + + expect( + resolveOrgTaskOperationOutcome( + { ...legacyTask(), outcome: "failed" }, + result, + "failed" + ) + ).toBe("rejected"); + }); + + it("keeps infrastructure-shaped task failures red", () => { + const result = { + content: "Error executing task_update: database connection closed", + }; + + expect( + resolveOrgTaskOperationOutcome( + { ...legacyTask(), outcome: "failed" }, + result, + "failed" + ) + ).toBe("failed"); + }); + + it("does not treat an args-only completed event as persisted", () => { + const extracted = legacyTask({ + task: { + id: "", + subject: "Attempted task", + status: "pending", + blocks: [], + blockedBy: [], + }, + }); + + expect(resolveOrgTaskOperationOutcome(extracted, {}, "completed")).toBe( + "failed" + ); + expect(isPersistedOrgTaskEvent(extracted, {}, "completed")).toBe(false); + }); + + it("recognizes a legacy persisted task result", () => { + const extracted = legacyTask({ + task: { + id: "task-1", + subject: "Persisted task", + status: "pending", + blocks: [], + blockedBy: [], + }, + }); + + expect(isPersistedOrgTaskEvent(extracted, {}, "completed")).toBe(true); + }); +}); diff --git a/src/engines/SessionCore/rendering/orgTaskOutcome.ts b/src/engines/SessionCore/rendering/orgTaskOutcome.ts new file mode 100644 index 000000000..08e5a5a16 --- /dev/null +++ b/src/engines/SessionCore/rendering/orgTaskOutcome.ts @@ -0,0 +1,145 @@ +import type { RustExtractedOrgTaskData } from "@src/engines/SessionCore/core/types"; + +export type ResolvedOrgTaskOperationOutcome = Exclude< + NonNullable, + "unknown" +>; + +function resultBoolean( + result: Record | undefined, + key: string +): boolean | undefined { + const value = result?.[key]; + return typeof value === "boolean" ? value : undefined; +} + +function resultString( + result: Record | undefined, + key: string +): string | undefined { + const value = result?.[key]; + return typeof value === "string" ? value : undefined; +} + +function isErrorText(value: string | undefined): boolean { + const normalized = value?.trimStart(); + return Boolean( + normalized?.startsWith("Error executing") || + normalized?.startsWith("Error:") + ); +} + +function isRejectedResult(result: Record | undefined) { + return ( + resultBoolean(result, "rejected") === true || + resultBoolean(result, "created") === false || + resultBoolean(result, "requires_dependency_confirmation") === true || + resultBoolean(result, "authorization_denied") === true || + resultBoolean(result, "already_exists") === true || + resultBoolean(result, "status_ignored") === true || + resultBoolean(result, "deleted") === false + ); +} + +function isRecoverableTaskValidationError( + result: Record | undefined +): boolean { + return ["error", "error_message", "content", "observation"].some((key) => { + const message = resultString(result, key)?.trimStart(); + return Boolean( + message?.startsWith("Error executing task_") && + message.includes(": Invalid parameters:") + ); + }); +} + +function normalizedResult( + result: Record | undefined +): Record | undefined { + if (!result) return undefined; + for (const key of ["content", "observation"] as const) { + const value = result[key]; + if (typeof value !== "string") continue; + try { + const parsed: unknown = JSON.parse(value); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed as Record; + } + } catch { + // Plain-text tool output is valid; the error-text checks below still + // inspect the unwrapped result. + } + } + return result; +} + +/** + * Resolve old `unknown` extracted payloads without turning an args-only task + * attempt into successful state. Newly extracted events always carry an + * explicit outcome; this fallback only protects persisted pre-outcome events. + */ +export function resolveOrgTaskOperationOutcome( + extracted: RustExtractedOrgTaskData, + result?: Record, + displayStatus?: string +): ResolvedOrgTaskOperationOutcome { + const resolvedResult = normalizedResult(result); + + // `outcome` is required on newly-produced payloads, but persisted events + // from older app versions can still omit it at runtime. Older extraction + // also labeled task-tool validation misuse as failed, so reclassify those + // known recoverable results before honoring the persisted outcome. + if (extracted.outcome && extracted.outcome !== "unknown") { + if ( + extracted.outcome === "failed" && + (isRecoverableTaskValidationError(resolvedResult) || + isRecoverableTaskValidationError(result)) + ) { + return "rejected"; + } + return extracted.outcome; + } + + if ( + isRejectedResult(resolvedResult) || + isRecoverableTaskValidationError(resolvedResult) || + isRecoverableTaskValidationError(result) + ) { + return "rejected"; + } + + if ( + isErrorText(resultString(resolvedResult, "error")) || + isErrorText(resultString(resolvedResult, "error_message")) || + isErrorText(resultString(result, "content")) || + isErrorText(resultString(result, "observation")) || + displayStatus === "failed" + ) { + return "failed"; + } + + if ( + displayStatus === "running" || + displayStatus === "pending" || + displayStatus === "awaiting_user" + ) { + return "pending"; + } + + const hasPersistedTask = Boolean(extracted.task?.id); + const hasTaskSnapshot = + extracted.action === "list" && + (extracted.orgRunId !== undefined || extracted.tasks !== undefined); + return hasPersistedTask || hasTaskSnapshot ? "succeeded" : "failed"; +} + +export function isPersistedOrgTaskEvent( + extracted: RustExtractedOrgTaskData, + result?: Record, + displayStatus?: string +): boolean { + return ( + resolveOrgTaskOperationOutcome(extracted, result, displayStatus) === + "succeeded" + ); +} diff --git a/src/engines/SessionCore/rendering/props/__tests__/propsDataExtractors.test.ts b/src/engines/SessionCore/rendering/props/__tests__/propsDataExtractors.test.ts index ab3216e5b..b86b23108 100644 --- a/src/engines/SessionCore/rendering/props/__tests__/propsDataExtractors.test.ts +++ b/src/engines/SessionCore/rendering/props/__tests__/propsDataExtractors.test.ts @@ -498,6 +498,42 @@ describe("extractFileData", () => { expect(data.startLine).toBeUndefined(); }); + it("uses imported offset/limit metadata for plain read output ranges", () => { + const props = makeUniversalProps({ + args: { + file_path: "src/app.rs", + offset: 249, + limit: 36, + }, + result: { output: "plain imported sed output" }, + }); + const data = extractFileData(props); + expect(data.content).toBe("plain imported sed output"); + expect(data.startLine).toBe(250); + expect(data.lineCount).toBe(36); + }); + + it("lets offset/limit repair stale rust extracted read ranges", () => { + const props = makeUniversalProps({ + args: { + file_path: "src/app.rs", + offset: 859, + limit: 41, + }, + rustExtracted: { + kind: "file", + filePath: "src/app.rs", + fileName: "app.rs", + content: "plain imported sed output", + language: "rust", + lineCount: 1, + }, + }); + const data = extractFileData(props); + expect(data.startLine).toBe(860); + expect(data.lineCount).toBe(41); + }); + it("reports startLine for ranged reads (offset/limit)", () => { const rangedContent = "[action: read_text]\n 120│fn main() {\n 121│}"; const props = makeUniversalProps({ @@ -772,6 +808,33 @@ describe("extractEditData", () => { expect(data.linesRemoved).toBe(1); }); + it("parses Codex Update File patches into unified diff", () => { + const patchText = [ + "*** Begin Patch", + "*** Update File: src/existing.ts", + "@@", + "-const old = true;", + "+const updated = true;", + " const unchanged = 42;", + "*** End Patch", + ].join("\n"); + const props = makeUniversalProps({ + args: { patch_text: patchText }, + }); + const data = extractEditData(props); + expect(data.filePath).toBe("src/existing.ts"); + expect(data.fileName).toBe("existing.ts"); + expect(data.diff).toContain("--- src/existing.ts"); + expect(data.diff).toContain("+++ src/existing.ts"); + expect(data.diff).toContain("-const old = true;"); + expect(data.diff).toContain("+const updated = true;"); + expect(data.diff).not.toContain("\n@@\n@@"); + expect(data.linesAdded).toBe(1); + expect(data.linesRemoved).toBe(1); + expect(data.applyPatchSegments).toHaveLength(1); + expect(data.applyPatchSegments?.[0]?.filePath).toBe("src/existing.ts"); + }); + it("multi-file patch sync path produces combined diff with per-file segments", () => { const patchText = [ "*** Begin Patch", @@ -803,6 +866,82 @@ describe("extractEditData", () => { expect(data.applyPatchSegments?.[2]?.filePath).toBe("src/c.ts"); }); + it("parses apply_patch text from args.patch", () => { + const patchText = [ + "*** Begin Patch", + "*** Update File: src/from-patch.ts", + "@@", + "-old", + "+new", + "*** End Patch", + ].join("\n"); + const props = makeUniversalProps({ + args: { patch: patchText }, + }); + const data = extractEditData(props); + expect(data.filePath).toBe("src/from-patch.ts"); + expect(data.fileName).toBe("from-patch.ts"); + expect(data.applyPatchSegments).toHaveLength(1); + expect(data.linesAdded).toBe(1); + expect(data.linesRemoved).toBe(1); + expect(data.oldStartLine).toBeUndefined(); + expect(data.newStartLine).toBeUndefined(); + expect(data.applyPatchSegments?.[0]?.oldStartLine).toBeUndefined(); + expect(data.applyPatchSegments?.[0]?.newStartLine).toBeUndefined(); + }); + + it("preserves explicit apply_patch hunk line ranges", () => { + const patchText = [ + "*** Begin Patch", + "*** Update File: src/ranged.ts", + "@@ -42,2 +43,2 @@", + "-old", + "+new", + "*** End Patch", + ].join("\n"); + const props = makeUniversalProps({ + args: { patch: patchText }, + }); + const data = extractEditData(props); + expect(data.filePath).toBe("src/ranged.ts"); + expect(data.oldStartLine).toBe(42); + expect(data.newStartLine).toBe(43); + expect(data.applyPatchSegments?.[0]?.oldStartLine).toBe(42); + expect(data.applyPatchSegments?.[0]?.newStartLine).toBe(43); + }); + + it("repairs stale Rust patch placeholders from args.patch", () => { + const patchText = [ + "*** Begin Patch", + "*** Update File: src/repaired.ts", + "@@", + "-const oldValue = true;", + "+const newValue = true;", + "*** End Patch", + ].join("\n"); + const props = makeUniversalProps({ + args: { patch: patchText }, + rustExtracted: { + kind: "edit", + filePath: "", + fileName: "patch", + language: "diff", + diff: "", + linesAdded: 0, + linesRemoved: 0, + isDeleted: false, + applyPatchSegments: [], + }, + }); + const data = extractEditData(props); + expect(data.filePath).toBe("src/repaired.ts"); + expect(data.fileName).toBe("repaired.ts"); + expect(data.applyPatchSegments).toHaveLength(1); + expect(data.applyPatchSegments?.[0]?.filePath).toBe("src/repaired.ts"); + expect(data.linesAdded).toBe(1); + expect(data.linesRemoved).toBe(1); + }); + it("computes line counts from patch diff lines", () => { const patchText = [ "*** Begin Patch", diff --git a/src/engines/SessionCore/rendering/props/editExtractors.ts b/src/engines/SessionCore/rendering/props/editExtractors.ts index 46435782b..cbe58af70 100644 --- a/src/engines/SessionCore/rendering/props/editExtractors.ts +++ b/src/engines/SessionCore/rendering/props/editExtractors.ts @@ -21,6 +21,7 @@ import { extractFileData } from "./fileExtractors"; const HUNK_HEADER_REGEX = /^@@\s+-(\d+)(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@/m; const DIFF_CODE_FENCE_REGEX = /```diff\s*\n([\s\S]*?)\n```/; +const HUNK_MARKER_REGEX = /^@@/; interface DiffLineMeta { oldStartLine?: number; @@ -47,6 +48,25 @@ function parseDiffLineMeta(diff: string | undefined): DiffLineMeta { // Main extractor // ============================================ +function patchTextFromArgs( + args: UniversalEventProps["args"] +): string | undefined { + if (typeof args?.patch_text === "string") return args.patch_text; + if (typeof args?.patch === "string") return args.patch; + if (typeof args?.input === "string") return args.input; + return undefined; +} + +function isPatchPlaceholder(edit: ExtractedEditData): boolean { + const filePath = edit.filePath.trim(); + const fileName = edit.fileName.trim(); + return ( + !edit.applyPatchSegments?.length && + (!filePath || filePath === "patch") && + (!fileName || fileName === "patch") + ); +} + export function extractEditData(props: UniversalEventProps): ExtractedEditData { if (props.rustExtracted?.kind === "edit") { const rust = props.rustExtracted; @@ -69,7 +89,7 @@ export function extractEditData(props: UniversalEventProps): ExtractedEditData { isDeleted: seg.isDeleted || undefined, })) : undefined; - return { + const editData = { filePath: rust.filePath, fileName: rust.fileName, content: rust.content, @@ -85,12 +105,18 @@ export function extractEditData(props: UniversalEventProps): ExtractedEditData { isDeleted: rust.isDeleted || undefined, applyPatchSegments, }; + const patchText = patchTextFromArgs(props.args); + if (patchText && isPatchPlaceholder(editData)) { + return extractApplyPatchData(patchText, props.result); + } + return editData; } const { args, result } = props; - if (args?.patch_text && typeof args.patch_text === "string") { - return extractApplyPatchData(args.patch_text as string, result); + const patchText = patchTextFromArgs(args); + if (patchText) { + return extractApplyPatchData(patchText, result); } const fileData = extractFileData(props); @@ -176,9 +202,20 @@ function extractApplyPatchData( const firstPath = parsed.filePaths.length > 0 ? parsed.filePaths[0] : ""; const rawFileName = getFileName(firstPath) || "patch"; - const applyPatchSegments = splitCombinedDiffIntoSegments(parsed.diff); + const applyPatchSegments = splitCombinedDiffIntoSegments(parsed.diff).map( + (segment) => + parsed.hasExplicitHunkHeaders + ? segment + : { + ...segment, + oldStartLine: undefined, + newStartLine: undefined, + } + ); - const diffMeta = parseDiffLineMeta(parsed.diff); + const diffMeta = parsed.hasExplicitHunkHeaders + ? parseDiffLineMeta(parsed.diff) + : {}; return { filePath: firstPath, @@ -194,7 +231,7 @@ function extractApplyPatchData( linesAdded: parsed.linesAdded, linesRemoved: parsed.linesRemoved, applyPatchSegments: - applyPatchSegments.length > 1 ? applyPatchSegments : undefined, + applyPatchSegments.length > 0 ? applyPatchSegments : undefined, }; } @@ -330,6 +367,7 @@ function patchSegmentToExtractedEdit( linesAdded: number; linesRemoved: number; isDeleted: boolean; + hasExplicitHunkHeader?: boolean; }, resultSummary: string | undefined, segmentIndex: number, @@ -353,7 +391,10 @@ function patchSegmentToExtractedEdit( const detectedLang = detectLanguage(rawFileName); const language = detectedLang === "plaintext" ? "diff" : detectedLang; - const diffMeta = parseDiffLineMeta(segment.diff); + const diffMeta = + segment.hasExplicitHunkHeader === false + ? {} + : parseDiffLineMeta(segment.diff); return { filePath: segment.filePath, @@ -383,6 +424,7 @@ interface SyncPatchResult { filePaths: string[]; linesAdded: number; linesRemoved: number; + hasExplicitHunkHeaders: boolean; } const MAX_PATCH_CACHE = 50; @@ -403,30 +445,48 @@ function convertPatchToUnifiedDiffSync(patchText: string): SyncPatchResult { const filePaths: string[] = []; let currentFile = ""; let isAddFile = false; + let isDeleteFile = false; let sectionLines: string[] = []; let totalAdded = 0; let totalRemoved = 0; + let hasExplicitHunkHeaders = false; const flushSection = () => { - if (!currentFile || sectionLines.length === 0) return; - if (isAddFile) { + if (!currentFile || (sectionLines.length === 0 && !isDeleteFile)) return; + const bodyLines = sectionLines.filter( + (line) => !HUNK_MARKER_REGEX.test(line) + ); + const hasExplicitHunkHeader = sectionLines.some((line) => + HUNK_HEADER_REGEX.test(line) + ); + hasExplicitHunkHeaders ||= isAddFile || hasExplicitHunkHeader; + if (isDeleteFile) { + diffLines.push(`--- ${currentFile}`); + diffLines.push("+++ /dev/null"); + diffLines.push(`@@ -1,${sectionLines.length} +0,0 @@`); + } else if (isAddFile) { diffLines.push("--- /dev/null"); diffLines.push(`+++ ${currentFile}`); - diffLines.push(`@@ -0,0 +1,${sectionLines.length} @@`); + diffLines.push(`@@ -0,0 +1,${bodyLines.length} @@`); } else { diffLines.push(`--- ${currentFile}`); diffLines.push(`+++ ${currentFile}`); - let added = 0; - let removed = 0; - let context = 0; - for (const sl of sectionLines) { - if (sl.startsWith("+")) added++; - else if (sl.startsWith("-")) removed++; - else context++; + if (!hasExplicitHunkHeader) { + let added = 0; + let removed = 0; + let context = 0; + for (const sl of bodyLines) { + if (sl.startsWith("+")) added++; + else if (sl.startsWith("-")) removed++; + else context++; + } + diffLines.push(`@@ -1,${removed + context} +1,${added + context} @@`); } - diffLines.push(`@@ -1,${removed + context} +1,${added + context} @@`); } for (const sl of sectionLines) { + if (HUNK_MARKER_REGEX.test(sl) && !HUNK_HEADER_REGEX.test(sl)) { + continue; + } if (sl.startsWith("+")) totalAdded++; else if (sl.startsWith("-")) totalRemoved++; diffLines.push(sl); @@ -436,14 +496,18 @@ function convertPatchToUnifiedDiffSync(patchText: string): SyncPatchResult { for (const line of lines) { const addMatch = line.match(/^\*\*\*\s+Add\s+File:\s+(.+)/); - const modifyMatch = line.match(/^\*\*\*\s+Modify\s+File:\s+(.+)/); + const modifyMatch = line.match( + /^\*\*\*\s+(?:Modify|Update)\s+File:\s+(.+)/ + ); const deleteMatch = line.match(/^\*\*\*\s+Delete\s+File:\s+(.+)/); + const moveMatch = line.match(/^\*\*\*\s+Move\s+to:\s+(.+)/); if (addMatch) { flushSection(); currentFile = addMatch[1].trim(); filePaths.push(currentFile); isAddFile = true; + isDeleteFile = false; continue; } if (modifyMatch) { @@ -451,16 +515,23 @@ function convertPatchToUnifiedDiffSync(patchText: string): SyncPatchResult { currentFile = modifyMatch[1].trim(); filePaths.push(currentFile); isAddFile = false; + isDeleteFile = false; continue; } if (deleteMatch) { flushSection(); - const deletedFile = deleteMatch[1].trim(); - filePaths.push(deletedFile); - diffLines.push(`--- ${deletedFile}`); - diffLines.push("+++ /dev/null"); - diffLines.push("@@ -1,0 +0,0 @@ deleted"); - currentFile = ""; + currentFile = deleteMatch[1].trim(); + filePaths.push(currentFile); + isAddFile = false; + isDeleteFile = true; + continue; + } + if (moveMatch) { + const movedFile = moveMatch[1].trim(); + if (movedFile) { + currentFile = movedFile; + filePaths[filePaths.length - 1] = movedFile; + } continue; } if ( @@ -480,6 +551,7 @@ function convertPatchToUnifiedDiffSync(patchText: string): SyncPatchResult { filePaths, linesAdded: totalAdded, linesRemoved: totalRemoved, + hasExplicitHunkHeaders, }; evictAndSet(patchCache, key, syncResult, MAX_PATCH_CACHE); return syncResult; diff --git a/src/engines/SessionCore/rendering/props/fileExtractors.ts b/src/engines/SessionCore/rendering/props/fileExtractors.ts index 3129c08e8..8a20b6822 100644 --- a/src/engines/SessionCore/rendering/props/fileExtractors.ts +++ b/src/engines/SessionCore/rendering/props/fileExtractors.ts @@ -19,17 +19,55 @@ import { stripLineNumberPrefixes, } from "./extractorShared"; +function payloadNumber( + payload: Record | undefined, + key: string +): number | undefined { + const value = payload?.[key]; + if (typeof value === "number" && Number.isFinite(value) && value >= 0) { + return value; + } + return undefined; +} + +function readLineMetadata(args: UniversalEventProps["args"]): { + startLine?: number; + lineCount?: number; +} { + const offset = payloadNumber(args, "offset"); + const limit = payloadNumber(args, "limit"); + if (limit === undefined) return {}; + return { + startLine: offset !== undefined ? offset + 1 : undefined, + lineCount: limit, + }; +} + export function extractFileData(props: UniversalEventProps): ExtractedFileData { + const metadata = readLineMetadata(props.args); + if (props.rustExtracted?.kind === "file" && props.rustExtracted.filePath) { const { filePath, fileName, content, language } = props.rustExtracted; const stripped = content ? stripLineNumberPrefixes(content) : undefined; + const startLine = + stripped?.startLine ?? + props.rustExtracted.startLine ?? + metadata.startLine; + const hasNumberedStart = + stripped?.startLine !== undefined || + props.rustExtracted.startLine !== undefined; + const lineCount = hasNumberedStart + ? (stripped?.lineCount ?? props.rustExtracted.lineCount) + : (metadata.lineCount ?? + props.rustExtracted.lineCount ?? + stripped?.lineCount); return { filePath, fileName, content: stripped?.content, language, - lineCount: stripped?.lineCount ?? props.rustExtracted.lineCount, - startLine: stripped?.startLine ?? props.rustExtracted.startLine, + lineCount, + startLine, }; } @@ -57,8 +95,11 @@ export function extractFileData(props: UniversalEventProps): ExtractedFileData { const stripped = rawContent ? stripLineNumberPrefixes(rawContent) : undefined; const content = stripped?.content; - const lineCount = stripped?.lineCount; - const startLine = stripped?.startLine; + const startLine = stripped?.startLine ?? metadata.startLine; + const lineCount = + stripped?.startLine !== undefined + ? stripped.lineCount + : (metadata.lineCount ?? stripped?.lineCount); const language = detectLanguage(fileName); diff --git a/src/engines/SessionCore/rendering/props/propsNormalizer.ts b/src/engines/SessionCore/rendering/props/propsNormalizer.ts index 89a5da8f5..569cd9e0d 100644 --- a/src/engines/SessionCore/rendering/props/propsNormalizer.ts +++ b/src/engines/SessionCore/rendering/props/propsNormalizer.ts @@ -17,7 +17,13 @@ */ import { useMemo } from "react"; -import type { SessionEvent } from "@src/engines/SessionCore/core/types"; +import { + LLM_USAGE_ARGS_KEY, + type LlmUsageMetadata, + type SessionEvent, + TOOL_USAGE_ARGS_KEY, + type ToolUsageMetadata, +} from "@src/engines/SessionCore/core/types"; import type { AnimationConfig, EventStatus, @@ -29,6 +35,26 @@ import { normalizeActivity } from "@src/lib/activityData"; const ACTIVE_EVENT_PAINTING_TTL_MS = 30 * 60 * 1000; +function readToolUsageMetadata( + args: Record, + eventToolUsage?: ToolUsageMetadata +): ToolUsageMetadata | undefined { + if (eventToolUsage) return eventToolUsage; + const raw = args[TOOL_USAGE_ARGS_KEY]; + if (!raw || typeof raw !== "object") return undefined; + return raw as ToolUsageMetadata; +} + +function readLlmUsageMetadata( + args: Record, + eventLlmUsage?: LlmUsageMetadata +): LlmUsageMetadata | undefined { + if (eventLlmUsage) return eventLlmUsage; + const raw = args[LLM_USAGE_ARGS_KEY]; + if (!raw || typeof raw !== "object") return undefined; + return raw as LlmUsageMetadata; +} + function shouldShowActiveEventPainting( status: EventStatus, createdAt?: string @@ -201,11 +227,15 @@ export function normalizeEventProps( const status = mapStatus( sessionEvent.displayStatus || inferStatusFromResult(result) ); + const toolUsage = readToolUsageMetadata(args, sessionEvent.toolUsage); + const llmUsage = readLlmUsageMetadata(args, sessionEvent.llmUsage); return { eventId: sessionEvent.id, eventType, functionName: sessionEvent.functionName, callId: sessionEvent.callId, + toolUsage, + llmUsage, filePath: sessionEvent.filePath, repoPath: sessionEvent.repoPath, sessionId: sessionEvent.sessionId, @@ -269,6 +299,7 @@ export function normalizeEventProps( (input as { repoPath?: string; repo_path?: string }).repo_path, args: normalized.args, result: normalized.result, + llmUsage: readLlmUsageMetadata(normalized.args), status, timestamp: normalized.createdAt, showActiveEventPainting: shouldShowActiveEventPainting( diff --git a/src/engines/SessionCore/rendering/registry/__tests__/eventRegistry.test.ts b/src/engines/SessionCore/rendering/registry/__tests__/eventRegistry.test.ts index dfaaff213..5c0206713 100644 --- a/src/engines/SessionCore/rendering/registry/__tests__/eventRegistry.test.ts +++ b/src/engines/SessionCore/rendering/registry/__tests__/eventRegistry.test.ts @@ -36,6 +36,7 @@ describe("COMPONENT_LOADERS", () => { "agent_message", "thinking", "user", + "context_compacted", "ask_user_questions", "ask_user_permissions", "subagent", @@ -51,7 +52,6 @@ describe("COMPONENT_LOADERS", () => { "turn_summary", "worktree", "setup_repo", - "suggest_next_steps", "rate_limit_hint", "tool_call", ] as const; diff --git a/src/engines/SessionCore/rendering/registry/events/index.ts b/src/engines/SessionCore/rendering/registry/events/index.ts index df6c2b4f1..35707e6db 100644 --- a/src/engines/SessionCore/rendering/registry/events/index.ts +++ b/src/engines/SessionCore/rendering/registry/events/index.ts @@ -103,10 +103,11 @@ export const COMPONENT_LOADERS: ComponentLoaderMap = { // ── Stream events ── // Custom render paths — NOT tool calls, have no `chat_block` in Rust. // Each carries behaviour the chat-block pipeline cannot express: - // `agent_message` — itemIndex-aware typewriter + markdown streaming - // `thinking` — reasoning-trace with custom collapse - // `user` — user-authored chat bubble - // `turn_summary` — stitches multiple child events into one card + // `agent_message` — itemIndex-aware typewriter + markdown streaming + // `thinking` — reasoning-trace with custom collapse + // `user` — user-authored chat bubble + // `turn_summary` — stitches multiple child events into one card + // `context_compacted` — collapsed compact-boundary summary marker agent_message: () => import("@src/engines/ChatPanel/events/stream/agent-message").then( (mod) => ({ @@ -131,6 +132,12 @@ export const COMPONENT_LOADERS: ComponentLoaderMap = { default: mod.RateLimitHintEvent as React.ComponentType, }) ), + context_compacted: () => + import("@src/engines/ChatPanel/events/stream/context-compacted").then( + (mod) => ({ + default: mod.ContextCompactedEvent as React.ComponentType, + }) + ), // ── Interactive events ── // State-mutating UI that owns its own input wiring. Custom render is @@ -138,7 +145,6 @@ export const COMPONENT_LOADERS: ComponentLoaderMap = { // `ask_user_questions` — form with submit flow (answers posted back) // `ask_user_permissions` — approve/deny buttons on PermissionCard // `suggest_mode_switch` — clickable card mutating `creatorDefaultExecModeAtom` - // `suggest_next_steps` — clickable cards that post a follow-up // // `plan_approval` uses chatBlockLoader (dispatches to `plan_doc` ChatBlock // → PlanDocAdapter). Explicit entry required so `_lazyComponentCache` is @@ -164,12 +170,6 @@ export const COMPONENT_LOADERS: ComponentLoaderMap = { default: mod.ModeSwitchEvent as React.ComponentType, }) ), - suggest_next_steps: () => - import("@src/engines/ChatPanel/events/interactive_events/next-step").then( - (mod) => ({ - default: mod.NextStepEvent as React.ComponentType, - }) - ), }; // ============================================ @@ -328,6 +328,12 @@ export const CONTEXT_CONFIG: Record = { simulator: { supportsSplitView: false, supportsFullscreen: false }, }, + // Compact boundary (context compacted marker) + context_compacted: { + chat: { requiresItemIndex: false, showStatusLine: false }, + simulator: { supportsSplitView: false, supportsFullscreen: false }, + }, + // Worktree worktree: { chat: { requiresItemIndex: false, showStatusLine: true }, @@ -346,12 +352,6 @@ export const CONTEXT_CONFIG: Record = { simulator: { supportsSplitView: false, supportsFullscreen: false }, }, - // Suggested next steps - suggest_next_steps: { - chat: { requiresItemIndex: false, showStatusLine: false }, - simulator: { supportsSplitView: false, supportsFullscreen: false }, - }, - // Generic fallback tool_call: { chat: { requiresItemIndex: false, showStatusLine: true }, diff --git a/src/engines/SessionCore/rendering/types/universalProps.ts b/src/engines/SessionCore/rendering/types/universalProps.ts index 3dde42573..e2153dcfd 100644 --- a/src/engines/SessionCore/rendering/types/universalProps.ts +++ b/src/engines/SessionCore/rendering/types/universalProps.ts @@ -10,7 +10,9 @@ */ import type { ExtractedData, + LlmUsageMetadata, PayloadRef, + ToolUsageMetadata, } from "@src/engines/SessionCore/core/types"; import type { PlanSurface } from "@src/engines/SessionCore/derived/planDisplayEvents"; @@ -88,6 +90,10 @@ export interface UniversalEventProps { * to the chat bubble for this tool. Absent on non-tool events. */ callId?: string; + /** Token/context attribution metadata for this tool call. */ + toolUsage?: ToolUsageMetadata; + /** Token usage metadata for the LLM span represented by this event. */ + llmUsage?: LlmUsageMetadata; /** File path for file operations, when emitted as top-level event metadata. */ filePath?: string; /** Repository filesystem path active when this event was emitted. */ @@ -210,6 +216,7 @@ export interface RustPatchSegment { linesAdded: number; linesRemoved: number; isDeleted: boolean; + hasExplicitHunkHeader?: boolean; } /** Mirrors Rust `PatchConversionResult` (serde camelCase). */ diff --git a/src/engines/SessionCore/services/SessionService.ts b/src/engines/SessionCore/services/SessionService.ts index 331700417..b6d5122e6 100644 --- a/src/engines/SessionCore/services/SessionService.ts +++ b/src/engines/SessionCore/services/SessionService.ts @@ -21,7 +21,11 @@ import { sessionLaunch, } from "@src/api/tauri/agent"; import { ROUTES } from "@src/config/routes"; -import { getAdapterForSession } from "@src/engines/SessionCore/sync"; +import { getAdapterForSession } from "@src/engines/SessionCore/sync/types"; +import { + buildPendingForkHandoff, + markForkHandoffConsumed, +} from "@src/features/TeamCollaboration/forkSession"; import { createLogger } from "@src/hooks/logger"; import { collectAdeContext } from "@src/services/context/collectors"; import { @@ -39,7 +43,6 @@ import { invokeTauri } from "@src/util/platform/tauri/init"; import { isAgentSession, isCliSession, - isCursorIdeSession, isExternalHistorySession, } from "@src/util/session/sessionDispatch"; @@ -87,11 +90,6 @@ function assertSupportsManagedOperation( sessionId: string, operation: string ): void { - if (isCursorIdeSession(sessionId)) { - throw new Error( - `Operation "${operation}" is not supported for Cursor IDE sessions (${sessionId}).` - ); - } if (isExternalHistorySession(sessionId)) { throw new Error( `Operation "${operation}" is not supported for imported external history sessions (${sessionId}).` @@ -101,7 +99,6 @@ function assertSupportsManagedOperation( function categoryForSession(sessionId: string): SessionInfo["category"] { if (isCliSession(sessionId)) return "cli_agent"; - if (isCursorIdeSession(sessionId)) return "cursor_ide"; if (isExternalHistorySession(sessionId)) return "external_history"; return "rust_agent"; } @@ -153,6 +150,9 @@ export const SessionService = { const launchParams = { category, content: params.task, + ...(params.imageDataUrls && params.imageDataUrls.length > 0 + ? { images: params.imageDataUrls } + : {}), workspacePath: params.projectRepoPath || params.repoPath || undefined, accountId: params.accountId || undefined, name: params.name || params.task.slice(0, 60), @@ -162,6 +162,7 @@ export const SessionService = { agentRole: params.agentRole || undefined, worktreePath: params.repoPath || undefined, keySource: params.keySource || undefined, + parentSessionId: params.parentSessionId || undefined, ideContext: adeContext, ...(params.projectSlug ? { projectSlug: params.projectSlug } : {}), ...(isCli @@ -313,11 +314,38 @@ export const SessionService = { ); } + // Fork relay (design §16.11): the FIRST real message sent to a forked + // session carries a bounded digest of the inherited teammate history, + // because the agent's LLM context is rebuilt from `agent_messages` — + // which a fork starts without. `displayText` keeps the user's own words + // in the transcript; the marker is consumed only after the send + // succeeds, so a failed send retries with the handoff intact. No-op for + // every non-forked session (durable one-shot marker, armed at fork time). + let effectiveContent = content; + let effectiveDisplayText = displayText; + let forkHandoffArmed = false; + if (!isResume) { + try { + const forkHandoff = await buildPendingForkHandoff(sessionId, content); + if (forkHandoff) { + effectiveContent = forkHandoff.content; + effectiveDisplayText = displayText ?? forkHandoff.displayText; + forkHandoffArmed = true; + } + } catch (handoffError) { + // Handoff assembly must never block a send — the fork still works, + // just without inherited context on this turn. + logger.warn( + `Fork handoff assembly failed for ${sessionId}: ${String(handoffError)}` + ); + } + } + try { await adapter.sendMessage({ sessionId, - content, - displayText, + content: effectiveContent, + displayText: effectiveDisplayText, model: model || undefined, accountId: accountId || undefined, mode: mode || undefined, @@ -328,6 +356,9 @@ export const SessionService = { adeContext, sessionRepoPath: sessionRow?.repoPath ?? null, }); + if (forkHandoffArmed) { + markForkHandoffConsumed(sessionId); + } // Float the row to the top of "today" in the sidebar without // waiting for the next session list refresh. The backend will // emit its own fresh `updated_at` on the next `loadSessions`, diff --git a/src/engines/SessionCore/services/types.ts b/src/engines/SessionCore/services/types.ts index 312914b6b..d605b579e 100644 --- a/src/engines/SessionCore/services/types.ts +++ b/src/engines/SessionCore/services/types.ts @@ -17,6 +17,8 @@ import type { DispatchCategory } from "@src/api/tauri/session"; export interface SessionCreateParams { /** User's task description */ task: string; + /** Base64 image data URLs attached to the initial user task. */ + imageDataUrls?: string[]; /** Repository path for the SDE agent to work in (agent_session_message's workspacePath) */ repoPath?: string; /** Project repo path where Work Items live (stored in session DB for orchestration notifications) */ @@ -50,6 +52,8 @@ export interface SessionCreateParams { listingModelType?: string; /** Marketplace price tier (hosted_key sessions) */ tier?: string; + /** Source session when this run is forked from imported or compacted history. */ + parentSessionId?: string; } export interface SessionMergeParams { diff --git a/src/engines/SessionCore/storage/cacheAdapter.ts b/src/engines/SessionCore/storage/cacheAdapter.ts index 81757f735..c9500ef97 100644 --- a/src/engines/SessionCore/storage/cacheAdapter.ts +++ b/src/engines/SessionCore/storage/cacheAdapter.ts @@ -45,9 +45,10 @@ export async function loadEvents(sessionId: string): Promise { } export async function loadTurnIndex( - sessionId: string + sessionId: string, + turnIds?: string[] ): Promise { - return sqliteCache.loadTurnIndex(sessionId); + return sqliteCache.loadTurnIndex(sessionId, turnIds); } export async function loadTurnBody( diff --git a/src/engines/SessionCore/storage/sqliteCache.ts b/src/engines/SessionCore/storage/sqliteCache.ts index 688866334..01bf3a39d 100644 --- a/src/engines/SessionCore/storage/sqliteCache.ts +++ b/src/engines/SessionCore/storage/sqliteCache.ts @@ -16,6 +16,7 @@ import { rpc } from "@src/api/tauri/rpc"; import { parseSessionSpecsJson } from "../core/schemas"; import type { EventPayloadBody, + ExtractedGitArtifactData, ReplayTimeRange, SessionEvent, SessionSpec, @@ -78,6 +79,16 @@ export interface TurnModifiedFile { deletions: number; } +export interface TurnResourceInteraction { + path: string; + fileName: string; + action: "read" | "write" | "create" | "delete" | "rename" | "search"; + outcome: "succeeded" | "failed" | "unknown"; + count: number; + firstOccurredAt: string; + lastOccurredAt: string; +} + export interface TurnSummary { sessionId: string; turnId: string; @@ -94,6 +105,8 @@ export interface TurnSummary { status: TurnStatus; interrupted: boolean; modifiedFiles: TurnModifiedFile[]; + resourceInteractions: TurnResourceInteraction[]; + gitArtifacts: ExtractedGitArtifactData[]; } export interface TurnBodyWindow { @@ -110,8 +123,11 @@ export async function loadEvents(sessionId: string): Promise { return rpc.sessionCore.cache.loadEvents({ sessionId }); } -export async function loadTurnIndex(sessionId: string): Promise { - return rpc.sessionCore.cache.loadTurnIndex({ sessionId }); +export async function loadTurnIndex( + sessionId: string, + turnIds?: string[] +): Promise { + return rpc.sessionCore.cache.loadTurnIndex({ sessionId, turnIds }); } export async function loadTurnBody( diff --git a/src/engines/SessionCore/sync/__tests__/getAdapterForSession.test.ts b/src/engines/SessionCore/sync/__tests__/getAdapterForSession.test.ts new file mode 100644 index 000000000..4e585dd19 --- /dev/null +++ b/src/engines/SessionCore/sync/__tests__/getAdapterForSession.test.ts @@ -0,0 +1,47 @@ +/** + * Regression coverage for adapter routing. + * + * Cursor IDE history carries a distinct `cursor_ide` *display* category (so the + * UI can separate imported IDE history from launched Cursor CLI sessions), but + * for *loading* it is read-only external history and must resolve to the + * `external_history` adapter. A prior change flipped Cursor's category to + * `cursor_ide` and left the loader gated on `isExternalHistorySession`, so + * `getAdapterForSession` returned `undefined` for Cursor sessions and the chat + * pane rendered an empty state. This test locks Cursor to a loading adapter. + */ +import { describe, expect, it } from "vitest"; + +// Importing the registry module registers every adapter as a side-effect, +// which is what `getAdapterForSession` reads from. +import "../adapters"; +import { getAdapterForSession } from "../types"; + +describe("getAdapterForSession", () => { + it("routes Cursor IDE history sessions to the external_history adapter", () => { + const adapter = getAdapterForSession( + "cursoride-b15be46d-5ced-468f-a5e3-441dd84fef93" + ); + expect(adapter?.category).toBe("external_history"); + }); + + it("routes other imported external history sessions to the same adapter", () => { + expect(getAdapterForSession("codexapp-abc")?.category).toBe( + "external_history" + ); + expect(getAdapterForSession("claudecodeapp-abc")?.category).toBe( + "external_history" + ); + expect(getAdapterForSession("opencodeapp-abc")?.category).toBe( + "external_history" + ); + }); + + it("still routes CLI and agent sessions to their own adapters", () => { + expect(getAdapterForSession("cliagent-abc")?.category).toBe("cli"); + expect(getAdapterForSession("osagent-abc")?.category).toBe("agent"); + }); + + it("returns undefined for unknown session ids", () => { + expect(getAdapterForSession("totally-unknown-abc")).toBeUndefined(); + }); +}); diff --git a/src/engines/SessionCore/sync/__tests__/sessionSyncStateHelpers.test.ts b/src/engines/SessionCore/sync/__tests__/sessionSyncStateHelpers.test.ts index b40a5b64a..feec504cd 100644 --- a/src/engines/SessionCore/sync/__tests__/sessionSyncStateHelpers.test.ts +++ b/src/engines/SessionCore/sync/__tests__/sessionSyncStateHelpers.test.ts @@ -9,6 +9,7 @@ import { markTurnTerminal, } from "@src/engines/SessionCore/control/turnLifecycle"; import { eventStoreProxy } from "@src/engines/SessionCore/core/store/EventStoreProxy"; +import { getLatestContextUsageSnapshot } from "@src/engines/SessionCore/sync/adapters/createRustAgentAdapter"; import { applyPostLoadResult, createSessionEventHandlerCallbacks, @@ -43,10 +44,13 @@ vi.mock("@src/engines/SessionCore/control/turnLifecycle", () => ({ })); function createActions(): SessionEventHandlerStateActions & { - streamingMap: Map; + streamingMap: Map; } { const actions = { - streamingMap: new Map(), + streamingMap: new Map< + string, + { kind: "message" | "thinking"; content: string } + >(), setSessionContextTokens: vi.fn(), setSessionContextUsage: vi.fn(), setSessionContextBreakdown: vi.fn(), @@ -54,6 +58,7 @@ function createActions(): SessionEventHandlerStateActions & { setSessionRuntimeError: vi.fn(), setPendingCancel: vi.fn(), setSessionRolledBack: vi.fn(), + dismissCanvasAtNewTurn: vi.fn(), setStreamingDeltaContent: vi.fn((update) => { actions.streamingMap = typeof update === "function" ? update(actions.streamingMap) : update; @@ -69,7 +74,10 @@ describe("session sync state callbacks", () => { it("clears live streaming content before completed status can leave Stop UI stuck", () => { const actions = createActions(); - actions.streamingMap.set("session-1", "live answer"); + actions.streamingMap.set("session-1", { + kind: "message", + content: "live answer", + }); const callbacks = createSessionEventHandlerCallbacks( "session-1", actions, @@ -81,7 +89,10 @@ describe("session sync state callbacks", () => { isThinking: false, content: "live answer", }); - expect(actions.streamingMap.get("session-1")).toBe("live answer"); + expect(actions.streamingMap.get("session-1")).toEqual({ + kind: "message", + content: "live answer", + }); callbacks.onStreamingDelta?.({ isStreaming: false, @@ -96,6 +107,30 @@ describe("session sync state callbacks", () => { expect(eventStoreProxy.unpinSession).toHaveBeenCalledWith("session-1"); }); + it("stores thinking deltas separately from assistant message deltas", () => { + const actions = createActions(); + actions.streamingMap.set("session-1", { + kind: "message", + content: "partial answer", + }); + const callbacks = createSessionEventHandlerCallbacks( + "session-1", + actions, + vi.fn() + ); + + callbacks.onStreamingDelta?.({ + isStreaming: true, + isThinking: true, + content: "reasoning token", + }); + + expect(actions.streamingMap.get("session-1")).toEqual({ + kind: "thinking", + content: "reasoning token", + }); + }); + it("marks terminal status changes as FSM turn terminals", () => { const actions = createActions(); const callbacks = createSessionEventHandlerCallbacks( @@ -161,6 +196,34 @@ describe("session sync state callbacks", () => { expect(markTurnRunning).toHaveBeenCalledWith("session-1"); }); + + it("calls dismissCanvasAtNewTurn with the session id when status is 'running'", () => { + const actions = createActions(); + const callbacks = createSessionEventHandlerCallbacks( + "session-42", + actions, + vi.fn() + ); + + callbacks.onStatusChange?.("running"); + + expect(actions.dismissCanvasAtNewTurn).toHaveBeenCalledWith("session-42"); + }); + + it("does not call dismissCanvasAtNewTurn for terminal statuses", () => { + const actions = createActions(); + const callbacks = createSessionEventHandlerCallbacks( + "session-1", + actions, + vi.fn() + ); + + for (const status of ["completed", "failed", "cancelled"]) { + callbacks.onStatusChange?.(status); + } + + expect(actions.dismissCanvasAtNewTurn).not.toHaveBeenCalled(); + }); }); describe("resetSessionSwitchState optimistic-running preservation", () => { @@ -259,3 +322,31 @@ describe("applyPostLoadResult", () => { expect(actions.setSessionContextUsage).toHaveBeenCalledWith(contextUsage); }); }); + +describe("getLatestContextUsageSnapshot", () => { + it("uses the latest persisted breakdown even when the newest token row has only totals", () => { + const contextUsage = { + usedTokens: 1200, + maxTokens: 8000, + percentUsed: 15, + updatedAt: "2026-06-25T00:00:00.000Z", + sections: [ + { + category: "conversation" as const, + label: "Conversation", + estimatedTokens: 1200, + percent: 100, + items: [], + }, + ], + warnings: [], + }; + + expect( + getLatestContextUsageSnapshot([ + { contextUsageJson: JSON.stringify(contextUsage) }, + { contextUsageJson: null }, + ]) + ).toEqual(contextUsage); + }); +}); diff --git a/src/engines/SessionCore/sync/__tests__/sessionSyncUtils.test.ts b/src/engines/SessionCore/sync/__tests__/sessionSyncUtils.test.ts new file mode 100644 index 000000000..cb6681f6d --- /dev/null +++ b/src/engines/SessionCore/sync/__tests__/sessionSyncUtils.test.ts @@ -0,0 +1,107 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; + +import { loadPersistedHistory } from "../sessionSyncUtils"; +import type { SessionAdapter } from "../types"; + +const cacheAdapterMock = vi.hoisted(() => ({ + loadInitialTurnWindow: vi.fn(), + loadEvents: vi.fn(), +})); + +vi.mock("@src/engines/SessionCore/storage/cacheAdapter", () => ({ + loadInitialTurnWindow: cacheAdapterMock.loadInitialTurnWindow, + loadEvents: cacheAdapterMock.loadEvents, +})); + +function makeEvent(id: string): SessionEvent { + return { id } as SessionEvent; +} + +function makeAdapter( + category: string, + historyEvents: SessionEvent[] +): SessionAdapter { + return { + category, + loadHistory: vi.fn(async () => historyEvents), + } as unknown as SessionAdapter; +} + +describe("loadPersistedHistory", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns turn-window events when the event cache has rows", async () => { + const events = [makeEvent("e1")]; + cacheAdapterMock.loadInitialTurnWindow.mockResolvedValue({ + turns: [{ turnId: "e1" }], + events, + }); + const adapter = makeAdapter("agent", [makeEvent("fallback")]); + + const result = await loadPersistedHistory( + adapter, + "sdeagent-x", + new AbortController().signal + ); + + expect(result).toBe(events); + expect(adapter.loadHistory).not.toHaveBeenCalled(); + }); + + it("falls back to adapter.loadHistory when the event cache is empty", async () => { + cacheAdapterMock.loadInitialTurnWindow.mockResolvedValue({ + turns: [], + events: [], + }); + cacheAdapterMock.loadEvents.mockResolvedValue([]); + const fallback = [makeEvent("from-agent-messages")]; + const adapter = makeAdapter("agent", fallback); + + const result = await loadPersistedHistory( + adapter, + "sdeagent-x", + new AbortController().signal + ); + + expect(result).toBe(fallback); + expect(adapter.loadHistory).toHaveBeenCalledTimes(1); + }); + + it("does not fall back when the signal is already aborted", async () => { + cacheAdapterMock.loadInitialTurnWindow.mockResolvedValue({ + turns: [], + events: [], + }); + cacheAdapterMock.loadEvents.mockResolvedValue([]); + const adapter = makeAdapter("agent", [makeEvent("fallback")]); + const controller = new AbortController(); + controller.abort(); + + const result = await loadPersistedHistory( + adapter, + "sdeagent-x", + controller.signal + ); + + expect(result).toEqual([]); + expect(adapter.loadHistory).not.toHaveBeenCalled(); + }); + + it("uses adapter.loadHistory directly for non-agent categories", async () => { + const fallback = [makeEvent("cli")]; + const adapter = makeAdapter("cli", fallback); + + const result = await loadPersistedHistory( + adapter, + "cli-x", + new AbortController().signal + ); + + expect(result).toBe(fallback); + expect(cacheAdapterMock.loadInitialTurnWindow).not.toHaveBeenCalled(); + }); +}); diff --git a/src/engines/SessionCore/sync/adapters/__tests__/externalHistoryAdapter.test.ts b/src/engines/SessionCore/sync/adapters/__tests__/externalHistoryAdapter.test.ts new file mode 100644 index 000000000..16b68fa3a --- /dev/null +++ b/src/engines/SessionCore/sync/adapters/__tests__/externalHistoryAdapter.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; + +import type { ActivityChunk } from "@src/types/session/session"; + +import { selectExternalHistoryInitialWindow } from "../externalHistoryAdapter"; + +function chunk(index: number, actionType = "tool_call", fn = "run_shell") { + return { + chunk_id: `chunk-${index}`, + action_type: actionType, + function: fn, + args: {}, + result: {}, + created_at: `2026-02-11T06:${String(index % 60).padStart(2, "0")}:00.000Z`, + } satisfies ActivityChunk; +} + +describe("external history initial window", () => { + it("returns short histories unchanged", () => { + const chunks = [chunk(0, "raw", "user_message"), chunk(1)]; + + expect(selectExternalHistoryInitialWindow(chunks)).toBe(chunks); + }); + + it("returns full histories for non-windowed sources", () => { + const chunks = Array.from({ length: 250 }, (_, index) => + chunk(index, index === 0 ? "raw" : "tool_call", "user_message") + ); + + expect( + selectExternalHistoryInitialWindow(chunks, { + supportsWindowedReplay: false, + }) + ).toBe(chunks); + }); + + it("expands the tail window back to the current user round", () => { + const chunks = [ + chunk(0, "raw", "user_message"), + chunk(1), + chunk(2, "raw", "user_message"), + ...Array.from({ length: 200 }, (_, index) => chunk(index + 3)), + ]; + + const window = selectExternalHistoryInitialWindow(chunks); + + expect(window[0].chunk_id).toBe("chunk-2"); + expect(window).toHaveLength(201); + }); + + it("falls back to the fixed tail when no user boundary is available", () => { + const chunks = Array.from({ length: 205 }, (_, index) => chunk(index)); + + const window = selectExternalHistoryInitialWindow(chunks); + + expect(window[0].chunk_id).toBe("chunk-5"); + expect(window).toHaveLength(200); + }); +}); diff --git a/src/engines/SessionCore/sync/adapters/cliAdapter.ts b/src/engines/SessionCore/sync/adapters/cliAdapter.ts index 94b4d028b..f521dccab 100644 --- a/src/engines/SessionCore/sync/adapters/cliAdapter.ts +++ b/src/engines/SessionCore/sync/adapters/cliAdapter.ts @@ -52,6 +52,10 @@ import { isStoreInitialized, } from "@src/util/core/state/instrumentedStore"; +import { + isNativeTranscriptSession, + registerSessionTranscriptSource, +} from "../nativeTranscriptReconcile"; import type { AdapterSendInput, EventHandlerCallbacks, @@ -70,7 +74,7 @@ import { buildToolArgsFromParsed, parsePartialToolArgs, } from "./shared/streamingParsers"; -import type { AgentWSEvent } from "./shared/types"; +import type { AgentWSEvent, PermissionRequestEvent } from "./shared/types"; const log = createLogger("CliAdapter"); @@ -116,6 +120,8 @@ interface StoredSession { status: string; errorMessage?: string | null; totalTokens?: number; + /** 'chunks' (legacy DB transcript) or 'native' (CLI's own store). */ + transcriptSource?: string; } type CliStatusResponse = { @@ -274,8 +280,18 @@ async function refreshLoadedCliHistory( new AbortController().signal ); if (events.length === 0) return events; - await eventStoreProxy.mergeEvents(events, sessionId); - getInstrumentedStore().set(loadSessionAtom, { sessionId, events }); + // Native-transcript sessions render the live turn from in-memory events + // only (optimistic synthetic bubble + streamed broadcasts); the replay is + // read here purely to OBSERVE persistence for the send handshake. Merging + // it mid-turn would sit replay rows (`codex-user-0`, the synthesized + // `user-input-*-synthesized` fallback) next to the synthetic bubble under + // never-matching ids — the ×3 user-bubble bug. The terminal reconcile + // (scheduleNativeTranscriptReconcile, replace semantics) is the single + // point where replay becomes the transcript on screen. + if (!isNativeTranscriptSession(sessionId)) { + await eventStoreProxy.mergeEvents(events, sessionId); + getInstrumentedStore().set(loadSessionAtom, { sessionId, events }); + } return events; } @@ -349,6 +365,11 @@ export const cliAdapter: SessionAdapter = { ); if (signal.aborted || !storedSession) return result; + registerSessionTranscriptSource( + sessionId, + storedSession.transcriptSource + ); + if (typeof storedSession.totalTokens === "number") { result.contextTokens = storedSession.totalTokens; } @@ -482,6 +503,15 @@ export const cliAdapter: SessionAdapter = { return typeof value === "string" && value.length > 0 ? value : undefined; } + function rawNumber(raw: RawSessionEvent, key: string): number | null { + const value = raw[key]; + return typeof value === "number" && Number.isFinite(value) ? value : null; + } + + function asNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; + } + function handlePlanReadyForApproval(raw: RawSessionEvent): void { const store = getStore(); if (!store) return; @@ -497,6 +527,7 @@ export const cliAdapter: SessionAdapter = { planId: rawString(raw, "planId"), planRevisionId: rawString(raw, "planRevisionId"), originToolCallId: rawString(raw, "originToolCallId"), + autoApproveAt: rawNumber(raw, "autoApproveAt"), }) ); } @@ -541,6 +572,7 @@ export const cliAdapter: SessionAdapter = { planId: asString(args.planId), planRevisionId: asString(args.planRevisionId), originToolCallId: asString(args.originToolCallId), + autoApproveAt: asNumber(args.autoApproveAt), }) ); normalizeChunkRust(chunk, sessionId) @@ -836,6 +868,37 @@ export const cliAdapter: SessionAdapter = { callbacks.onTokenUpdate?.(totalTokens); } + /** + * `permission:request` with `origin: "cli_hook"` or `"acp"` — a + * managed CLI session's PermissionRequest hook (Claude) or an ACP + * agent's `session/request_permission` (OpenCode/Copilot/Kiro) is + * parked on the backend waiting for the user. Mirror of the + * Rust-agent adapter's handlePermissionRequest: dispatch the window + * CustomEvent that PermissionCard consumes, tagged so its response + * routes back to the CLI registries (`cli_agent_approval_response`) + * instead of `agent_permission_response`. + */ + function handleCliPermissionRequest(raw: RawSessionEvent): void { + const origin = raw.origin; + if (origin !== "cli_hook" && origin !== "acp") return; + const requestId = rawString(raw, "requestId"); + if (!requestId) return; + const permEvent: PermissionRequestEvent = { + requestId, + sessionId, + tool: rawString(raw, "toolName") ?? rawString(raw, "tool") ?? "unknown", + toolCallId: rawString(raw, "toolCallId"), + args: + raw.toolArgs && typeof raw.toolArgs === "object" + ? (raw.toolArgs as Record) + : {}, + origin, + }; + window.dispatchEvent( + new CustomEvent("agent-permission-request", { detail: permEvent }) + ); + } + return { handleEvent(raw: RawSessionEvent): void { const msgSessionId = @@ -844,6 +907,8 @@ export const cliAdapter: SessionAdapter = { if (raw.type === "agent:interaction_finalized") { handleInteractionFinalized(raw as unknown as AgentWSEvent, sessionId); + } else if (raw.type === "permission:request") { + handleCliPermissionRequest(raw); } else if (raw.type === "agent:plan_ready_for_approval") { handlePlanReadyForApproval(raw); } else if (raw.type === "agent:exit_plan_mode") { diff --git a/src/engines/SessionCore/sync/adapters/createRustAgentAdapter.ts b/src/engines/SessionCore/sync/adapters/createRustAgentAdapter.ts index d884fe0e7..59b28ec16 100644 --- a/src/engines/SessionCore/sync/adapters/createRustAgentAdapter.ts +++ b/src/engines/SessionCore/sync/adapters/createRustAgentAdapter.ts @@ -14,7 +14,6 @@ */ import { cancelSession, - enterAgentOrgSessionIntervention, getSession, getSessionInfo, loadMessages, @@ -32,6 +31,7 @@ import type { ContextUsageSnapshot } from "@src/store/session/cliSessionStatusAt import { invokeTauri } from "@src/util/platform/tauri/init"; import { retryInvokeTauri } from "@src/util/platform/tauri/retryInvoke"; +import { noteSessionChannelActivity } from "../sessionChannelActivity"; import type { AdapterSendInput, AgentTokenUsageInfo, @@ -53,6 +53,11 @@ import { noteSessionStreamingTurn, resetAllStreamingState, } from "./rustAgent/eventHandlers/streamHelpers"; +import { + applyLlmUsageToEvents, + applyToolUsageToEvents, + loadUsageTelemetry, +} from "./rustAgent/toolUsageCache"; import type { AgentTokenUsage, AgentWSEvent, @@ -104,6 +109,18 @@ function parseContextUsageSnapshot( } } +export function getLatestContextUsageSnapshot( + records: readonly { contextUsageJson?: string | null }[] +): ContextUsageSnapshot | undefined { + for (let index = records.length - 1; index >= 0; index -= 1) { + const contextUsage = parseContextUsageSnapshot( + records[index]?.contextUsageJson + ); + if (contextUsage) return contextUsage; + } + return undefined; +} + function toTokenUsageInfo(usage: AgentTokenUsage): AgentTokenUsageInfo { return { promptTokens: usage.promptTokens, @@ -117,6 +134,26 @@ function toTokenUsageInfo(usage: AgentTokenUsage): AgentTokenUsageInfo { }; } +async function refreshUsageForLatestEvents(sessionId: string): Promise { + const { toolUsageByCallId, llmUsageByTurnId } = + await loadUsageTelemetry(sessionId); + if (toolUsageByCallId.size === 0 && llmUsageByTurnId.size === 0) return; + + const snapshot = eventStoreProxy.getLatestSessionSnapshot(sessionId); + const events = snapshot?.chatEvents ?? []; + const hydratedEvents = applyLlmUsageToEvents( + applyToolUsageToEvents(events, toolUsageByCallId), + llmUsageByTurnId + ); + const updates = hydratedEvents.flatMap((event, index) => { + if (event.args === events[index]?.args) return []; + return [ + eventStoreProxy.updateById(event.id, { args: event.args }, sessionId), + ]; + }); + await Promise.all(updates); +} + // ============================================================================ // Factory // ============================================================================ @@ -158,8 +195,16 @@ export function createRustAgentAdapter( const merged = await mergeToolResults(events); if (signal.aborted) return merged; - await backfillSubagentLinks(sessionId, merged); - return merged; + const { toolUsageByCallId, llmUsageByTurnId } = + await loadUsageTelemetry(sessionId); + if (signal.aborted) return merged; + const usageHydrated = applyLlmUsageToEvents( + applyToolUsageToEvents(merged, toolUsageByCallId), + llmUsageByTurnId + ); + + await backfillSubagentLinks(sessionId, usageHydrated); + return usageHydrated; }, async postLoad( @@ -224,7 +269,7 @@ export function createRustAgentAdapter( const fill = last.contextTokens > 0 ? last.contextTokens : last.inputTokens; if (fill > 0) result.contextTokens = fill; - const contextUsage = parseContextUsageSnapshot(last.contextUsageJson); + const contextUsage = getLatestContextUsageSnapshot(records); if (contextUsage) result.contextUsage = contextUsage; } } catch (err) { @@ -281,6 +326,9 @@ export function createRustAgentAdapter( onContextUsage: (contextUsage) => { callbacks.onContextUsage?.(contextUsage); }, + onTokenUpdate: (tokens) => { + callbacks.onTokenUpdate?.(tokens); + }, onStatusChange: ( status: string, errorMessage?: string, @@ -350,6 +398,10 @@ export function createRustAgentAdapter( // turn (backgrounded/exited especially can fire long after agent:complete). // - agent:exec_output — streaming output from background processes; can arrive // after agent:complete when a backgrounded command is still running. + // - agent:context_usage — context-ring bookkeeping (token counts after a + // turn or a manual compaction). The manual-compact pipeline broadcasts + // it with no turn running at all; treating it as a turn signal leaves + // the composer stuck on Stop with no terminal event ever coming. // - agent:computer_use_entered / agent:computer_use_exited — desktop/Wingman // CU-lock lifecycle. `exited` is broadcast by the processor immediately // after `agent:complete` (see processor.rs §9a½), so if it were treated @@ -367,6 +419,7 @@ export function createRustAgentAdapter( "agent:shell_process_backgrounded", "agent:shell_process_exited", "agent:exec_output", + "agent:context_usage", "agent:computer_use_entered", "agent:computer_use_exited", ]); @@ -383,6 +436,13 @@ export function createRustAgentAdapter( handleEvent(raw: RawSessionEvent): void { if (_disposed) return; + // Liveness stamp for EVERY channel event, before any filtering. + // Ephemeral events (tool_call_delta, stream_retry) never reach the + // EventStore, so this is the only place their arrival is recorded; + // the planning watchdog reads it to distinguish "backend still + // streaming" from "backend went silent". + noteSessionChannelActivity(sessionId); + const payload = raw.payload && typeof raw.payload === "object" ? raw.payload : {}; const event = { @@ -469,6 +529,12 @@ export function createRustAgentAdapter( if (isTerminal) { _runningSignaled = false; _turnCompleted = true; + void refreshUsageForLatestEvents(sessionId).catch((err) => { + logger.warn( + `[${category}] terminal tool usage refresh failed:`, + err + ); + }); } }) .catch((err) => { @@ -554,9 +620,6 @@ export function createRustAgentAdapter( // when two sessions on different repos are open simultaneously. const activePath = sessionRepoPath ?? undefined; clearSessionStreamingStopped(sessionId); - if (!isResume && content.trim()) { - await enterAgentOrgSessionIntervention(sessionId); - } await retryInvokeTauri( "agent_send_message", { diff --git a/src/engines/SessionCore/sync/adapters/cursorIdeAdapter.ts b/src/engines/SessionCore/sync/adapters/cursorIdeAdapter.ts index 50b552250..cc0210b48 100644 --- a/src/engines/SessionCore/sync/adapters/cursorIdeAdapter.ts +++ b/src/engines/SessionCore/sync/adapters/cursorIdeAdapter.ts @@ -1,78 +1,26 @@ /** - * Cursor IDE Session Adapter + * Cursor IDE EventStore preload helpers. * - * Surfaces Cursor IDE chat history (read from `~/.../state.vscdb`) as - * sessions in our app **and** lets the user send new prompts back into - * the live Cursor probe instance through {@link sendMessage}. - * - * - `loadHistory` reads bubbles from Cursor's DB via `cursor_ide_chunks` - * and pipes them through the same Rust normalizer (`processChunksRust`) - * that CLI sessions use, so `ChatHistory` and the simulator render them - * without any UI-layer changes. - * - `createEventHandler` handles `code_session.activity` delta events - * delivered via the long-lived CDP watch established by `sendMessage`. - * Each `assistant_delta` chunk with `is_delta: true` is accumulated - * locally for the typewriter effect. The polling hook - * `useCursorIdeFocusPoll` continues to run as fallback for tool-call - * bubbles and final state replacement. - * - `sendMessage` runs the probe flow: `ensureRunning` → - * optional `setModel` → optional `setMode` → headless `send` - * (composer-targeted, no UI route) → start CDP watch via - * `cursorBridgeWatchComposer` → forced reload of the EventStore. - * - `stopSession` cancels the CDP watch via `cursorBridgeUnwatchComposer` - * (no ORGII-side process to stop; Cursor turn cancellation is Cursor's own). + * Cursor IDE history is loaded through the generic external-history adapter. + * This module only keeps Cursor-specific lazy preload/snapshot helpers used by + * turn expansion and session-switch freshness checks. */ -import { - cursorBridgeComposerLastUpdatedAt, - cursorBridgeEnsureRealCursorRunning, - cursorBridgeSend, - cursorBridgeSetMode, - cursorBridgeSetModel, - cursorBridgeUnwatchComposer, - cursorBridgeWatchComposer, -} from "@src/api/tauri/cursorBridge"; -import { promptRestartCursorWithDebugPort } from "@src/api/tauri/cursorBridge/restartDialog"; import { cursorIdeFullRefresh, cursorIdeInitialWindow, -} from "@src/api/tauri/cursorIde"; +} from "@src/api/tauri/externalHistory"; +import { cursorIdeComposerLastUpdatedAt } from "@src/api/tauri/externalHistory/cursorIde"; import { eventStoreProxy } from "@src/engines/SessionCore/core/store/EventStoreProxy"; -import type { SessionEvent } from "@src/engines/SessionCore/core/types"; import { processChunksRust } from "@src/engines/SessionCore/ingestion/rustBridge"; -import { makeAssistantEvent } from "@src/engines/SessionCore/sync/adapters/shared/eventBuilders"; -import { createStreamMessageId } from "@src/engines/SessionCore/sync/utils/activityIds"; -import { createLogger } from "@src/hooks/logger"; import { cursorIdeTurnSummariesAtomFamily } from "@src/store/session/cursorIdeTurnSummariesAtom"; -import { cursorModeOverrideAtomFamily } from "@src/store/session/cursorModeOverrideAtom"; import { getInstrumentedStore } from "@src/util/core/state/instrumentedStore"; import { composerIdFromSessionId, isCursorIdeSession, } from "@src/util/session/sessionDispatch"; -import type { - AdapterSendInput, - EventHandlerCallbacks, - RawSessionEvent, - SessionAdapter, - SessionEventHandler, -} from "../types"; - -const logger = createLogger("CursorIdeAdapter"); - -const CURSOR_IDE_CATEGORY = "cursor_ide"; const CURSOR_IDE_INITIAL_RECENT_BUBBLE_LIMIT = 100; -/** - * In-flight `sendMessage` calls keyed by sessionId. A second prompt for the - * same Cursor composer must not race the first: both would tear down and - * replace each other's CDP watch (`cursorBridgeWatchComposer` "replaces any - * existing watch automatically"), orphaning the first prompt's delta stream. - * The guard serializes per-session sends so each prompt's watch outlives - * its own dispatch. - */ -const _inFlightSends = new Map>(); - const cursorIdeSnapshotLastUpdatedAtBySession = new Map(); export function getCursorIdeSnapshotLastUpdatedAt( @@ -86,7 +34,7 @@ async function refreshCursorIdeSnapshotLastUpdatedAt( ): Promise { const composerId = composerIdFromSessionId(sessionId); if (!composerId) return; - const lastUpdatedAt = await cursorBridgeComposerLastUpdatedAt(composerId); + const lastUpdatedAt = await cursorIdeComposerLastUpdatedAt(composerId); if (lastUpdatedAt !== null) { cursorIdeSnapshotLastUpdatedAtBySession.set(sessionId, lastUpdatedAt); } @@ -243,273 +191,3 @@ async function loadCursorIdeEventsIntoStore( await eventStoreProxy.set(events, sessionId); await refreshCursorIdeSnapshotLastUpdatedAt(sessionId); } - -function buildCursorDeltaEvent( - streamId: string, - sessionId: string, - content: string, - startedAt: string -): SessionEvent { - const base = makeAssistantEvent(streamId, sessionId, content, true); - return { - ...base, - createdAt: startedAt, - result: { - content, - observation: content, - role: "assistant", - is_delta: true, - }, - }; -} - -export const cursorIdeAdapter: SessionAdapter = { - category: CURSOR_IDE_CATEGORY, - - async loadHistory(sessionId, signal) { - const initialWindow = await cursorIdeInitialWindow({ - sessionId, - recentLimit: CURSOR_IDE_INITIAL_RECENT_BUBBLE_LIMIT, - }); - if (signal.aborted) return []; - getInstrumentedStore().set( - cursorIdeTurnSummariesAtomFamily(sessionId), - initialWindow.turns - ); - const { chunks } = initialWindow; - if (!Array.isArray(chunks) || chunks.length === 0) { - return []; - } - const events = await processChunksRust(chunks, sessionId); - if (signal.aborted) return []; - await refreshCursorIdeSnapshotLastUpdatedAt(sessionId); - return events; - }, - - async postLoad() { - return {}; - }, - - createEventHandler( - sessionId: string, - callbacks: EventHandlerCallbacks - ): SessionEventHandler { - let _streaming = false; - let msgContent = ""; - let msgStreamId = ""; - let msgStartedAt = ""; - - function setStreamingMode(active: boolean): void { - if (_streaming !== active) { - _streaming = active; - eventStoreProxy.setStreaming(active, sessionId); - } - } - - // NOTE: this adapter deliberately does NOT drive `onStatusChange`. - // The CDP delta stream has no terminal "answer finished" event — the - // deltas simply stop — so the adapter cannot emit a balanced - // running → completed pair. Cursor IDE session runtime status is - // owned by the polling fallback (`useCursorIdeFocusPoll`), which reads - // the authoritative final state from Cursor's own DB. - - function clearMessageStream(): void { - msgContent = ""; - msgStreamId = ""; - msgStartedAt = ""; - } - - return { - handleEvent(raw: RawSessionEvent): void { - const msgSessionId = - (raw.session_id as string) || (raw.sessionId as string); - if (msgSessionId !== sessionId) return; - - // Only handle streaming delta chunks from the CDP watch - if (raw.type !== "code_session.activity" || !raw.chunk) return; - - const chunk = raw.chunk as Record; - const actionType = chunk.action_type as string | undefined; - const result = chunk.result as Record | undefined; - const isDelta = result?.is_delta === true; - - if ( - isDelta && - (actionType === "assistant_delta" || - actionType === "assistant" || - actionType === "message_delta" || - actionType === "message") - ) { - setStreamingMode(true); - const deltaText = - (result?.content as string) || - (result?.observation as string) || - ""; - if (!deltaText) return; - - if (!msgStreamId) { - msgStreamId = createStreamMessageId(sessionId); - msgStartedAt = new Date().toISOString(); - } - msgContent += deltaText; - eventStoreProxy.upsert( - buildCursorDeltaEvent( - msgStreamId, - sessionId, - msgContent, - msgStartedAt - ), - sessionId - ); - // Feed the partial-recovery cache so a mid-stream crash/reload - // can replay the in-flight Cursor answer. - callbacks.onStreamingDelta?.({ - isStreaming: true, - isThinking: false, - content: msgContent, - }); - } - }, - - reset(): void { - clearMessageStream(); - _streaming = false; - eventStoreProxy.setStreaming(false, sessionId); - }, - - get isStreaming(): boolean { - return _streaming; - }, - - dispose(): void { - this.reset(); - }, - }; - }, - - async sendMessage(input: AdapterSendInput): Promise { - const { sessionId, content } = input; - if (!content.trim()) return; - - // Serialize per-session sends. A double-click or queue-flush firing a - // second prompt while the first is mid-flight would otherwise let each - // call replace the other's CDP watch, orphaning the first delta stream. - const inflight = _inFlightSends.get(sessionId); - if (inflight) { - logger.warn( - `sendMessage already in-flight for ${sessionId}; chaining after it` - ); - await inflight.catch(() => { - // Swallow the prior send's failure here — it was already surfaced - // to its own caller; this call gets a clean attempt. - }); - } - - const work = runCursorIdeSend(input); - _inFlightSends.set(sessionId, work); - try { - await work; - } finally { - if (_inFlightSends.get(sessionId) === work) { - _inFlightSends.delete(sessionId); - } - } - }, - - async stopSession(sessionId: string): Promise { - // Cancel the long-lived CDP watch for this session (if any). - // Cursor turn cancellation is owned by Cursor itself. - cursorBridgeUnwatchComposer({ sessionId }).catch((err: unknown) => { - logger.warn("cursorBridgeUnwatchComposer failed:", err); - }); - }, -}; - -/** - * Core send sequence for a Cursor IDE prompt. Extracted from - * `sendMessage` so the public method can wrap it in the per-session - * in-flight guard. - * - * Ordering matters: the CDP delta watch MUST be installed (awaited) - * before the EventStore force-reload. The watch installs a - * MutationObserver in the Cursor renderer; if the reload completes - * first, any token deltas emitted in the gap are lost and the first - * chunk of the answer never animates. - */ -async function runCursorIdeSend(input: AdapterSendInput): Promise { - const { sessionId, content, model } = input; - const text = content.trim(); - if (!text) return; - const composerId = composerIdFromSessionId(sessionId); - - // Follow-ups must target the real Cursor DB that owns the composer. - // If the user's real Cursor is running without the debug port, ask - // before restarting it hidden so the same DB stays authoritative. - try { - await cursorBridgeEnsureRealCursorRunning(); - } catch (cursorError) { - const restarted = await promptRestartCursorWithDebugPort(cursorError); - if (!restarted) throw cursorError; - await cursorBridgeEnsureRealCursorRunning(); - } - - // Apply per-send model override before dispatching the prompt. - // Failure here is non-fatal: we fall through to send with whatever - // Cursor already has selected — but it MUST be logged so a silently - // ignored model pick is diagnosable. - if (model && composerId) { - try { - await cursorBridgeSetModel({ agentId: composerId, modelName: model }); - } catch (modelErr: unknown) { - logger.warn( - `setModel(${model}) failed for ${composerId}; sending with Cursor's current model:`, - modelErr - ); - } - } - - // Apply per-send unified mode override from the per-session atom that - // `CursorModePill` writes into. Same lazy-commit posture as the model setter. - if (composerId) { - const pickedMode = getInstrumentedStore().get( - cursorModeOverrideAtomFamily(sessionId) - ); - if (pickedMode) { - try { - await cursorBridgeSetMode({ agentId: composerId, modeId: pickedMode }); - } catch (modeErr: unknown) { - logger.warn( - `setMode(${pickedMode}) failed for ${composerId}; sending with Cursor's current mode:`, - modeErr - ); - } - } - } - - await cursorBridgeSend({ text, targetAgentId: composerId ?? undefined }); - - // Start streaming delta watch BEFORE the force-reload. The watch injects - // a MutationObserver into the Cursor renderer and forwards each token - // delta to `createEventHandler` via the `code_session.activity` event. - // Any existing watch for this session is replaced automatically. - // - // This is awaited (unlike the historical fire-and-forget version) so the - // observer is guaranteed live before the reload completes — otherwise the - // first deltas race the reload and the typewriter effect skips them. - if (composerId) { - try { - await cursorBridgeWatchComposer({ sessionId, composerId }); - } catch (watchErr: unknown) { - // Non-fatal: the polling fallback (`useCursorIdeFocusPoll`) still - // surfaces the final state. Log so the missing typewriter is traceable. - logger.warn( - `cursorBridgeWatchComposer failed for ${sessionId}; falling back to poll:`, - watchErr - ); - } - } - - // Force-reload the EventStore so the user message and any pre-stream - // bubbles surface immediately without waiting for the next poll tick. - await ensureCursorIdeEventsInStore(sessionId, { forceReload: true }); -} diff --git a/src/engines/SessionCore/sync/adapters/externalHistoryAdapter.ts b/src/engines/SessionCore/sync/adapters/externalHistoryAdapter.ts index fe2d6ce7c..3c2b44ce7 100644 --- a/src/engines/SessionCore/sync/adapters/externalHistoryAdapter.ts +++ b/src/engines/SessionCore/sync/adapters/externalHistoryAdapter.ts @@ -1,7 +1,8 @@ -import { getImportedHistorySourceBySessionId } from "@src/api/tauri/importedHistory"; +import { getImportedHistorySourceBySessionId } from "@src/api/tauri/externalHistory"; import type { SessionEvent } from "@src/engines/SessionCore/core/types"; import { processChunksRust } from "@src/engines/SessionCore/ingestion/rustBridge"; import { createLogger } from "@src/hooks/logger"; +import type { ActivityChunk } from "@src/types/session/session"; import type { AdapterSendInput, @@ -11,6 +12,41 @@ import type { } from "../types"; const logger = createLogger("ExternalHistoryAdapter"); +const EXTERNAL_HISTORY_INITIAL_CHUNK_LIMIT = 200; + +function isUserMessageChunk(chunk: ActivityChunk): boolean { + return ( + chunk.action_type === "raw" && + (chunk.function === "user_message" || chunk.function === "user") + ); +} + +export function selectExternalHistoryInitialWindow( + chunks: ActivityChunk[], + options: { supportsWindowedReplay?: boolean } = {} +): ActivityChunk[] { + if (options.supportsWindowedReplay === false) { + return chunks; + } + + if (chunks.length <= EXTERNAL_HISTORY_INITIAL_CHUNK_LIMIT) { + return chunks; + } + + let startIndex = Math.max( + 0, + chunks.length - EXTERNAL_HISTORY_INITIAL_CHUNK_LIMIT + ); + const tailStartIndex = startIndex; + while (startIndex > 0 && !isUserMessageChunk(chunks[startIndex])) { + startIndex -= 1; + } + if (!isUserMessageChunk(chunks[startIndex])) { + startIndex = tailStartIndex; + } + + return chunks.slice(startIndex); +} function createNoopEventHandler(): SessionEventHandler { return { @@ -32,11 +68,14 @@ async function loadExternalHistory( logger.warn("No external history loader registered for session", sessionId); return []; } - const chunks = await source.loadChunks(sessionId); + const chunks = await source.loadPreviewChunks(sessionId); if (signal.aborted || !Array.isArray(chunks) || chunks.length === 0) { return []; } - const events = await processChunksRust(chunks, sessionId); + const initialWindow = selectExternalHistoryInitialWindow(chunks, { + supportsWindowedReplay: source.supportsWindowedReplay, + }); + const events = await processChunksRust(initialWindow, sessionId); if (signal.aborted) return []; return events; } diff --git a/src/engines/SessionCore/sync/adapters/index.ts b/src/engines/SessionCore/sync/adapters/index.ts index 410d19c0c..d86fd5748 100644 --- a/src/engines/SessionCore/sync/adapters/index.ts +++ b/src/engines/SessionCore/sync/adapters/index.ts @@ -7,7 +7,6 @@ import { registerAdapter } from "../types"; import { cliAdapter } from "./cliAdapter"; import { AGENT_CONFIG, createRustAgentAdapter } from "./createRustAgentAdapter"; -import { cursorIdeAdapter } from "./cursorIdeAdapter"; import { externalHistoryAdapter } from "./externalHistoryAdapter"; /** Unified agent adapter — handles all Rust-native agents (OS, SDE, custom). */ @@ -15,6 +14,5 @@ export const agentAdapter = createRustAgentAdapter(AGENT_CONFIG); registerAdapter(agentAdapter); registerAdapter(cliAdapter); -registerAdapter(cursorIdeAdapter); registerAdapter(externalHistoryAdapter); -export { cliAdapter, cursorIdeAdapter, externalHistoryAdapter }; +export { cliAdapter, externalHistoryAdapter }; diff --git a/src/engines/SessionCore/sync/adapters/rustAgent/__tests__/toolUsageCache.test.ts b/src/engines/SessionCore/sync/adapters/rustAgent/__tests__/toolUsageCache.test.ts new file mode 100644 index 000000000..a44fdbfc9 --- /dev/null +++ b/src/engines/SessionCore/sync/adapters/rustAgent/__tests__/toolUsageCache.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, it } from "vitest"; + +import { TOOL_USAGE_ATTRIBUTION_METHOD } from "@src/api/tauri/session"; +import { + LLM_USAGE_ARGS_KEY, + type SessionEvent, + TOOL_USAGE_ARGS_KEY, +} from "@src/engines/SessionCore/core/types"; + +import { + applyLlmUsageToEvents, + applyToolUsageToEvents, + buildLlmUsageByTurnMap, + buildUsageMap, + withToolUsageArgs, +} from "../toolUsageCache"; + +function makeEvent(callId?: string, turnId?: string): SessionEvent { + return { + id: callId ? `tool-call-${callId}` : `message-${turnId ?? "1"}`, + chunk_id: null, + sessionId: "session-1", + createdAt: "2026-06-28T00:00:00.000Z", + functionName: callId ? "read_file" : "assistant_message", + uiCanonical: callId ? "read_file" : "assistant_message", + actionType: callId ? "tool_call" : "assistant", + args: { path: "README.md", ...(turnId ? { turnId } : {}) }, + result: {}, + source: "assistant", + displayText: "Read file", + displayStatus: "completed", + displayVariant: callId ? "tool_call" : "message", + activityStatus: "agent", + callId, + }; +} + +describe("toolUsageCache", () => { + it("aggregates attribution records by callId", () => { + const usageByCallId = buildUsageMap( + [ + { + id: 1, + sessionId: "session-1", + turnId: "turn-1", + eventId: "tool-call-call-1", + toolCallId: "call-1", + toolName: "read_file", + iterationIndex: 1, + decisionCompletionTokens: 10, + resultContextTokens: 20, + followupCompletionTokens: 0, + inputBytes: 100, + outputBytes: 200, + attributionMethod: + TOOL_USAGE_ATTRIBUTION_METHOD.SINGLE_TOOL_ITERATION, + createdAt: "2026-06-28T00:00:00.000Z", + }, + { + id: 2, + sessionId: "session-1", + turnId: "turn-1", + eventId: "tool-call-call-1", + toolCallId: "call-1", + toolName: "read_file", + iterationIndex: 2, + decisionCompletionTokens: 3, + resultContextTokens: 5, + followupCompletionTokens: 7, + inputBytes: 11, + outputBytes: 13, + attributionMethod: + TOOL_USAGE_ATTRIBUTION_METHOD.SINGLE_TOOL_ITERATION, + createdAt: "2026-06-28T00:00:01.000Z", + }, + ], + [ + { + id: 1, + sessionId: "session-1", + turnId: "turn-1", + iterationIndex: 1, + model: "model-1", + accountId: "account-1", + promptTokens: 100, + completionTokens: 20, + cacheReadTokens: 12, + cacheWriteTokens: 5, + totalTokens: 137, + contextTokens: 117, + relatedToolCallIdsJson: '["call-1"]', + contextUsageJson: null, + createdAt: "2026-06-28T00:00:00.000Z", + }, + ] + ); + + const expected = { + decisionCompletionTokens: 13, + resultContextTokens: 25, + followupCompletionTokens: 7, + inputBytes: 111, + outputBytes: 213, + relatedCacheReadTokens: 12, + relatedCacheWriteTokens: 5, + attributionMethod: TOOL_USAGE_ATTRIBUTION_METHOD.SINGLE_TOOL_ITERATION, + }; + expect(usageByCallId.get("call-1")).toEqual(expected); + expect(usageByCallId.get("tool-call-call-1")).toEqual(expected); + }); + + it("attaches usage to matching events without fetching per block", () => { + const toolUsage = { + decisionCompletionTokens: 10, + resultContextTokens: 25, + followupCompletionTokens: 0, + inputBytes: 100, + outputBytes: 200, + relatedCacheReadTokens: 0, + relatedCacheWriteTokens: 0, + attributionMethod: TOOL_USAGE_ATTRIBUTION_METHOD.BYTES_ONLY, + }; + const events = [makeEvent("call-1"), makeEvent("call-2"), makeEvent()]; + const enriched = applyToolUsageToEvents( + events, + new Map([["call-1", toolUsage]]) + ); + + expect(enriched[0].toolUsage).toEqual(toolUsage); + expect(enriched[0].args[TOOL_USAGE_ARGS_KEY]).toEqual(toolUsage); + expect(enriched[1].toolUsage).toBeUndefined(); + expect(enriched[2].toolUsage).toBeUndefined(); + }); + + it("attaches non-tool LLM span usage to the assistant event for a turn", () => { + const usageByTurnId = buildLlmUsageByTurnMap([ + { + id: 1, + sessionId: "session-1", + turnId: "turn-1", + iterationIndex: 1, + model: "model-1", + accountId: null, + promptTokens: 100, + completionTokens: 20, + cacheReadTokens: 4, + cacheWriteTokens: 2, + totalTokens: 126, + contextTokens: 100, + relatedToolCallIdsJson: "[]", + contextUsageJson: null, + createdAt: "2026-06-28T00:00:00.000Z", + }, + { + id: 2, + sessionId: "session-1", + turnId: "turn-1", + iterationIndex: 2, + model: "model-1", + accountId: null, + promptTokens: 80, + completionTokens: 10, + cacheReadTokens: 0, + cacheWriteTokens: 0, + totalTokens: 90, + contextTokens: 80, + relatedToolCallIdsJson: '["call-1"]', + contextUsageJson: null, + createdAt: "2026-06-28T00:00:01.000Z", + }, + ]); + const enriched = applyLlmUsageToEvents( + [makeEvent(undefined, "turn-1")], + usageByTurnId + ); + + expect(enriched[0].llmUsage).toEqual({ + inputTokens: 100, + outputTokens: 20, + cacheReadTokens: 4, + cacheWriteTokens: 2, + model: "model-1", + attributionMethod: TOOL_USAGE_ATTRIBUTION_METHOD.PROVIDER_EXACT, + }); + expect(enriched[0].args[LLM_USAGE_ARGS_KEY]).toEqual(enriched[0].llmUsage); + }); + + it("stores usage metadata in args patch payloads", () => { + const usage = { + decisionCompletionTokens: 1, + resultContextTokens: 2, + followupCompletionTokens: 3, + inputBytes: 4, + outputBytes: 5, + relatedCacheReadTokens: 6, + relatedCacheWriteTokens: 7, + attributionMethod: TOOL_USAGE_ATTRIBUTION_METHOD.SPLIT_EVENLY, + }; + + expect(withToolUsageArgs({ existing: true }, usage)).toEqual({ + existing: true, + [TOOL_USAGE_ARGS_KEY]: usage, + }); + }); +}); diff --git a/src/engines/SessionCore/sync/adapters/rustAgent/eventHandlers/__tests__/createPlanFinalization.test.ts b/src/engines/SessionCore/sync/adapters/rustAgent/eventHandlers/__tests__/createPlanFinalization.test.ts index c14a4227f..f51f0527b 100644 --- a/src/engines/SessionCore/sync/adapters/rustAgent/eventHandlers/__tests__/createPlanFinalization.test.ts +++ b/src/engines/SessionCore/sync/adapters/rustAgent/eventHandlers/__tests__/createPlanFinalization.test.ts @@ -1,13 +1,21 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { SessionEvent } from "@src/engines/SessionCore/core/types"; +import type { + AgentWSEvent, + PlanReadyForApprovalEvent, +} from "@src/engines/SessionCore/sync/adapters/shared/types"; import { handlePlanApprovalArchived, handlePlanReadyForApproval, } from "../agentSpecific"; import { handleToolCallDelta } from "../streamHandlers"; -import { handleToolCall, handleToolResult } from "../toolHandlers"; +import { + handleInteractionFinalized, + handleToolCall, + handleToolResult, +} from "../toolHandlers"; import type { EventHandlerContext } from "../types"; vi.hoisted(() => { @@ -36,6 +44,7 @@ const { mergeSpy, patchByIdsSpy, completeLastRunningSpy, + switchModeForSessionSpy, } = vi.hoisted(() => { const events = new Map(); const upsert = vi.fn((event: SessionEvent) => { @@ -84,6 +93,7 @@ const { mergeSpy: merge, patchByIdsSpy: patchByIds, completeLastRunningSpy: vi.fn(), + switchModeForSessionSpy: vi.fn().mockResolvedValue(undefined), }; }); @@ -104,6 +114,13 @@ vi.mock( }) ); +vi.mock( + "@src/engines/ChatPanel/InputArea/ModeSwitchCard/useModeSwitchActions", + () => ({ + switchModeForSession: switchModeForSessionSpy, + }) +); + vi.mock("@src/store/session/mcpProgressAtom", () => ({ clearMcpProgressForCallAtom: {}, })); @@ -123,6 +140,7 @@ function createCtx( execOutputBufferRef: ref(""), onAgentCompleteRef: ref(undefined), onContextUsageRef: ref(undefined), + onTokenUpdateRef: ref(undefined), onStatusChangeRef: ref(undefined), onQuestionRequestRef: ref(undefined), setStreaming: vi.fn(), @@ -139,6 +157,7 @@ beforeEach(() => { mergeSpy.mockClear(); patchByIdsSpy.mockClear(); completeLastRunningSpy.mockClear(); + switchModeForSessionSpy.mockClear(); vi.stubGlobal("window", { dispatchEvent: vi.fn(), }); @@ -166,21 +185,27 @@ describe("create_plan streaming finalization", () => { setStreaming: streamingSpy, }); - handlePlanReadyForApproval( - { - type: "agent:plan_ready_for_approval", - sessionId: "session-1", - planPath: "/tmp/plan.md", - planTitle: "Plan", - planContent: "body", - toolCallId: "call_plan_1", - planEventSource: "create_plan", - }, - "session-1", - ctx - ); + const event: AgentWSEvent & + Pick = { + type: "agent:plan_ready_for_approval", + sessionId: "session-1", + planPath: "/tmp/plan.md", + planTitle: "Plan", + planContent: "body", + toolCallId: "call_plan_1", + planEventSource: "create_plan", + autoApproveAt: 123_456, + }; + + handlePlanReadyForApproval(event, "session-1", ctx); expect(setSpy).toHaveBeenCalledTimes(1); + const [, updatePendingPlan] = setSpy.mock.calls[0]; + const nextPendingPlanMap = updatePendingPlan(new Map()); + expect(nextPendingPlanMap.get("session-1")?.current).toMatchObject({ + planTitle: "Plan", + autoApproveAt: 123_456, + }); expect(streamingSpy).toHaveBeenCalledWith(false); expect(statusSpy).toHaveBeenCalledWith("completed"); }); @@ -220,6 +245,32 @@ describe("create_plan streaming finalization", () => { ); }); + it("resumes the session when mode switch is auto-accepted by timeout", async () => { + await handleInteractionFinalized( + { + type: "agent:interaction_finalized", + sessionId: "session-1", + tool: "suggest_mode_switch", + toolCallId: "call_mode_1", + resultObject: { + choice: "switch", + targetMode: "plan", + auto: "timeout", + }, + resultPreview: + "Mode-switch suggestion timed out; auto-switching to plan mode.", + }, + "session-1" + ); + + expect(mergeSpy).toHaveBeenCalledTimes(1); + expect(switchModeForSessionSpy).toHaveBeenCalledWith( + "session-1", + "tool-call-call_mode_1", + "plan" + ); + }); + it("buffers streamed create_plan deltas without creating duplicate EventStore rows", async () => { const ctx = createCtx(); diff --git a/src/engines/SessionCore/sync/adapters/rustAgent/eventHandlers/__tests__/sessionHandlers.test.ts b/src/engines/SessionCore/sync/adapters/rustAgent/eventHandlers/__tests__/sessionHandlers.test.ts index 31a6ab4cd..21c6c9a04 100644 --- a/src/engines/SessionCore/sync/adapters/rustAgent/eventHandlers/__tests__/sessionHandlers.test.ts +++ b/src/engines/SessionCore/sync/adapters/rustAgent/eventHandlers/__tests__/sessionHandlers.test.ts @@ -32,6 +32,7 @@ function createCtx(): EventHandlerContext { execOutputBufferRef: ref(""), onAgentCompleteRef: ref(undefined), onContextUsageRef: ref(undefined), + onTokenUpdateRef: ref(undefined), onStatusChangeRef: ref(vi.fn()), onQuestionRequestRef: ref(undefined), setStreaming: vi.fn(), @@ -121,6 +122,28 @@ describe("Rust Agent session handlers", () => { expect(onContextUsage).toHaveBeenCalledWith(contextUsage); }); + it("updates context token totals without replacing an existing breakdown snapshot", () => { + const onContextUsage = vi.fn(); + const onTokenUpdate = vi.fn(); + const ctx = { + ...createCtx(), + onContextUsageRef: ref(onContextUsage), + onTokenUpdateRef: ref(onTokenUpdate), + }; + + handleContextUsage( + { + type: "agent:context_usage", + sessionId: "session-1", + contextTokens: 80, + }, + ctx + ); + + expect(onTokenUpdate).toHaveBeenCalledWith(80); + expect(onContextUsage).not.toHaveBeenCalled(); + }); + it("passes contextUsage from agent:complete to completion callbacks", () => { const onAgentComplete = vi.fn(); const ctx = { diff --git a/src/engines/SessionCore/sync/adapters/rustAgent/eventHandlers/__tests__/streamHandlers.test.ts b/src/engines/SessionCore/sync/adapters/rustAgent/eventHandlers/__tests__/streamHandlers.test.ts index 53f989447..d185f3cbd 100644 --- a/src/engines/SessionCore/sync/adapters/rustAgent/eventHandlers/__tests__/streamHandlers.test.ts +++ b/src/engines/SessionCore/sync/adapters/rustAgent/eventHandlers/__tests__/streamHandlers.test.ts @@ -49,6 +49,7 @@ function createCtx(): EventHandlerContext { onStreamingDeltaRef: ref(vi.fn()), onAgentCompleteRef: ref(undefined), onContextUsageRef: ref(undefined), + onTokenUpdateRef: ref(undefined), onStatusChangeRef: ref(vi.fn()), onQuestionRequestRef: ref(undefined), setStreaming: vi.fn(), diff --git a/src/engines/SessionCore/sync/adapters/rustAgent/eventHandlers/__tests__/toolHandlers.test.ts b/src/engines/SessionCore/sync/adapters/rustAgent/eventHandlers/__tests__/toolHandlers.test.ts new file mode 100644 index 000000000..5db045a8e --- /dev/null +++ b/src/engines/SessionCore/sync/adapters/rustAgent/eventHandlers/__tests__/toolHandlers.test.ts @@ -0,0 +1,115 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; +import type { AgentWSEvent } from "@src/engines/SessionCore/sync/adapters/shared/types"; + +import { handleToolResult } from "../toolHandlers"; +import type { EventHandlerContext } from "../types"; + +const { events, updateByIdSpy, getEventsSpy } = vi.hoisted(() => { + const eventMap = new Map(); + return { + events: eventMap, + updateByIdSpy: vi.fn( + (id: string, patch: Partial, _sessionId?: string) => { + const existing = eventMap.get(id); + if (existing) eventMap.set(id, { ...existing, ...patch }); + } + ), + getEventsSpy: vi.fn(async () => Array.from(eventMap.values())), + }; +}); + +vi.mock("@src/engines/SessionCore/core/store/EventStoreProxy", () => ({ + eventStoreProxy: { + getEvents: getEventsSpy, + updateById: updateByIdSpy, + }, +})); + +vi.mock( + "@src/engines/ChatPanel/blocks/CanvasInlineCard/openInSimulatorCanvas", + () => ({ + openInSimulatorCanvas: vi.fn(), + }) +); + +vi.mock("@src/store/session/mcpProgressAtom", () => ({ + clearMcpProgressForCallAtom: {}, +})); + +function ref(value: T): { current: T } { + return { current: value }; +} + +function createCtx(): EventHandlerContext { + return { + filterSessionIdRef: ref("session-1"), + assistantStreamRef: ref({ idRef: ref(""), contentRef: ref("") }), + thinkingStreamRef: ref({ idRef: ref(""), contentRef: ref("") }), + toolCallDeltaBuffersRef: ref(new Map()), + execOutputBufferRef: ref(""), + trackedCodingSessionsRef: ref(new Map()), + onAgentCompleteRef: ref(undefined), + onContextUsageRef: ref(undefined), + onTokenUpdateRef: ref(undefined), + onStatusChangeRef: ref(undefined), + onQuestionRequestRef: ref(undefined), + setStreaming: vi.fn(), + features: { hasCodingSessionBridge: true }, + getDefaultStore: () => null, + }; +} + +function parentAgentEvent(): SessionEvent { + return { + id: "parent-agent-call", + chunk_id: "parent-agent-call", + sessionId: "session-1", + createdAt: "2026-07-04T22:52:45.000Z", + functionName: "agent", + uiCanonical: "subagent", + actionType: "tool_call", + args: { + subagentSessionId: + "agent-builtin:explore-69cdc86a-24d1-42a9-9bbb-0a6025068f79", + action: "delegate", + }, + result: { + content: + "Subagent launched. Session ID: agent-builtin:explore-69cdc86a-24d1-42a9-9bbb-0a6025068f79", + }, + source: "assistant", + displayText: "agent", + displayStatus: "completed", + displayVariant: "tool_call", + activityStatus: "processed", + callId: "call-agent-1", + }; +} + +describe("rust agent tool result handler", () => { + beforeEach(() => { + events.clear(); + updateByIdSpy.mockClear(); + getEventsSpy.mockClear(); + }); + + it("does not downgrade an authoritative completed subagent parent card back to running", async () => { + events.set("parent-agent-call", parentAgentEvent()); + + const event: AgentWSEvent = { + type: "agent:tool_result", + sessionId: "session-1", + tool: "agent", + toolCallId: "call-agent-1", + result: + "Subagent launched. Session ID: agent-builtin:explore-69cdc86a-24d1-42a9-9bbb-0a6025068f79", + }; + + await handleToolResult(event, "session-1", createCtx()); + + expect(updateByIdSpy).not.toHaveBeenCalled(); + expect(events.get("parent-agent-call")?.displayStatus).toBe("completed"); + }); +}); diff --git a/src/engines/SessionCore/sync/adapters/rustAgent/eventHandlers/agentSpecific.ts b/src/engines/SessionCore/sync/adapters/rustAgent/eventHandlers/agentSpecific.ts index 972311fbd..95a961082 100644 --- a/src/engines/SessionCore/sync/adapters/rustAgent/eventHandlers/agentSpecific.ts +++ b/src/engines/SessionCore/sync/adapters/rustAgent/eventHandlers/agentSpecific.ts @@ -203,6 +203,8 @@ function makePlanApprovalEvent(options: { }; } +type PlanReadyPayload = Pick; + export function handlePlanReadyForApproval( event: AgentWSEvent, eventSessionId: string | undefined, @@ -214,6 +216,7 @@ export function handlePlanReadyForApproval( const store = ctx.getDefaultStore(); if (!store) return; + const planReadyPayload = event as AgentWSEvent & PlanReadyPayload; const detail: PlanReadyForApprovalEvent = { sessionId: eventSessionId, planPath, @@ -223,6 +226,10 @@ export function handlePlanReadyForApproval( planId: event.planId, planRevisionId: event.planRevisionId, originToolCallId: event.originToolCallId, + autoApproveAt: + typeof planReadyPayload.autoApproveAt === "number" + ? planReadyPayload.autoApproveAt + : null, }; store.set(pendingPlanApprovalsAtom, (prev) => diff --git a/src/engines/SessionCore/sync/adapters/rustAgent/eventHandlers/context.ts b/src/engines/SessionCore/sync/adapters/rustAgent/eventHandlers/context.ts index 15fd7d29d..d3d293e68 100644 --- a/src/engines/SessionCore/sync/adapters/rustAgent/eventHandlers/context.ts +++ b/src/engines/SessionCore/sync/adapters/rustAgent/eventHandlers/context.ts @@ -38,6 +38,7 @@ export function createEventHandlerContext( execOutputBufferRef: ref(""), onAgentCompleteRef: ref(callbacks.onAgentComplete), onContextUsageRef: ref(callbacks.onContextUsage), + onTokenUpdateRef: ref(callbacks.onTokenUpdate), onStatusChangeRef: ref(callbacks.onStatusChange), onQuestionRequestRef: ref(callbacks.onQuestionRequest), setStreaming: callbacks.setStreaming, diff --git a/src/engines/SessionCore/sync/adapters/rustAgent/eventHandlers/index.ts b/src/engines/SessionCore/sync/adapters/rustAgent/eventHandlers/index.ts index 42eea53b1..afbc25dc5 100644 --- a/src/engines/SessionCore/sync/adapters/rustAgent/eventHandlers/index.ts +++ b/src/engines/SessionCore/sync/adapters/rustAgent/eventHandlers/index.ts @@ -251,8 +251,7 @@ export async function dispatchAgentEvent( handleAdeAction(event); break; case "agent:todos_updated": - if (ctx.features.hasFileChangeEvents) - handleTodosUpdated(event, eventSessionId, ctx); + handleTodosUpdated(event, eventSessionId, ctx); break; case "permission:request": if (ctx.features.hasPermissionRequest) diff --git a/src/engines/SessionCore/sync/adapters/rustAgent/eventHandlers/sessionHandlers.ts b/src/engines/SessionCore/sync/adapters/rustAgent/eventHandlers/sessionHandlers.ts index dd5affe63..7f13c1fc2 100644 --- a/src/engines/SessionCore/sync/adapters/rustAgent/eventHandlers/sessionHandlers.ts +++ b/src/engines/SessionCore/sync/adapters/rustAgent/eventHandlers/sessionHandlers.ts @@ -36,19 +36,13 @@ export function handleContextUsage( event: AgentWSEvent, ctx: EventHandlerContext ): void { - if (typeof event.contextTokens === "number" && event.contextTokens > 0) { - ctx.onContextUsageRef.current?.( - event.contextUsage ?? { - usedTokens: event.contextTokens, - maxTokens: null, - percentUsed: null, - updatedAt: new Date().toISOString(), - sections: [], - warnings: [], - } - ); - } else if (event.contextUsage) { + if (event.contextUsage) { ctx.onContextUsageRef.current?.(event.contextUsage); + } else if ( + typeof event.contextTokens === "number" && + event.contextTokens > 0 + ) { + ctx.onTokenUpdateRef.current?.(event.contextTokens); } } @@ -307,6 +301,13 @@ export function handleSessionEvicted( resetAllStreamingState(ctx); ctx.setStreaming(false); clearStreamRetryStatus(ctx, sessionId); + // Mirror the Rust eviction in the JS snapshot cache — otherwise the JS + // heap keeps the full event arrays of a session Rust already dropped. + // Listeners are kept: if the session reloads, mounted consumers must keep + // receiving pushes. + if (sessionId) { + eventStoreProxy.releaseSessionSnapshot(sessionId); + } ctx.onStatusChangeRef.current?.("completed"); } diff --git a/src/engines/SessionCore/sync/adapters/rustAgent/eventHandlers/subagentHandlers.ts b/src/engines/SessionCore/sync/adapters/rustAgent/eventHandlers/subagentHandlers.ts index 68c89a234..9599ca61f 100644 --- a/src/engines/SessionCore/sync/adapters/rustAgent/eventHandlers/subagentHandlers.ts +++ b/src/engines/SessionCore/sync/adapters/rustAgent/eventHandlers/subagentHandlers.ts @@ -16,6 +16,8 @@ import { eventStoreProxy } from "@src/engines/SessionCore/core/store/EventStoreP import { SPAWNING_TOOLS_ARRAY } from "../../shared/subagentTracking"; import type { AgentWSEvent } from "../../shared/types"; +import { handleTodosUpdated } from "./agentSpecific"; +import { getEventSessionId } from "./streamHelpers"; import type { EventHandlerContext } from "./types"; // ============================================================================ @@ -25,9 +27,14 @@ import type { EventHandlerContext } from "./types"; export function handleCodingSessionEvent( event: AgentWSEvent, _parentEventId: string, - _ctx: EventHandlerContext + ctx: EventHandlerContext ): void { switch (event.type) { + case "agent:todos_updated": { + handleTodosUpdated(event, getEventSessionId(event), ctx); + break; + } + case "agent:tool_call": case "agent:tool_result": case "agent:message_delta": diff --git a/src/engines/SessionCore/sync/adapters/rustAgent/eventHandlers/toolHandlers.ts b/src/engines/SessionCore/sync/adapters/rustAgent/eventHandlers/toolHandlers.ts index 43815f317..b01919ac0 100644 --- a/src/engines/SessionCore/sync/adapters/rustAgent/eventHandlers/toolHandlers.ts +++ b/src/engines/SessionCore/sync/adapters/rustAgent/eventHandlers/toolHandlers.ts @@ -4,8 +4,12 @@ * Handlers for tool_call, tool_result, and interaction finalization events. * Shell process / exec-output handlers live in shellHandlers.ts. */ +import { switchModeForSession } from "@src/engines/ChatPanel/InputArea/ModeSwitchCard/useModeSwitchActions"; import { openInSimulatorCanvas } from "@src/engines/ChatPanel/blocks/CanvasInlineCard/openInSimulatorCanvas"; -import type { CanvasInlineMode } from "@src/engines/ChatPanel/blocks/CanvasInlineCard/types"; +import type { + CanvasInlineMode, + CanvasInlinePayload, +} from "@src/engines/ChatPanel/blocks/CanvasInlineCard/types"; import { eventStoreProxy } from "@src/engines/SessionCore/core/store/EventStoreProxy"; import { createLogger } from "@src/hooks/logger"; import { clearMcpProgressForCallAtom } from "@src/store/session/mcpProgressAtom"; @@ -23,6 +27,17 @@ import type { EventHandlerContext } from "./types"; const log = createLogger("ToolHandlers"); +function isAutoModeSwitchAccept( + tool: string | undefined, + resultObject: Record +): boolean { + return ( + tool === "suggest_mode_switch" && + resultObject.choice === "switch" && + resultObject.auto === "timeout" + ); +} + export function handleToolCall( event: AgentWSEvent, sessionId: string, @@ -133,6 +148,7 @@ export async function handleToolResult( const resultContent = bufferedOutput || event.result || ""; let trackedParentEventId: string | null = null; + let shouldMarkParentRunning = false; if ( typeof resultContent === "string" && @@ -148,6 +164,7 @@ export async function handleToolResult( if (activeIdx >= 0) { const activeEvent = events[activeIdx]; trackedParentEventId = activeEvent.id; + shouldMarkParentRunning = true; ctx.trackedCodingSessionsRef.current.set( codingSessionId, activeEvent.id @@ -161,9 +178,10 @@ export async function handleToolResult( } // The result row itself is already in the Rust EventStore. The broadcast - // result is only used here for subagent-session detection above. + // result is only used here for subagent-session detection above; never + // downgrade a completed authoritative parent event back to running. - if (trackedParentEventId) { + if (trackedParentEventId && shouldMarkParentRunning) { eventStoreProxy.updateById( trackedParentEventId, { @@ -189,15 +207,6 @@ function isCanvasInlineMode(value: unknown): value is CanvasInlineMode { ); } -interface CanvasInlineDispatchPayload { - mode: CanvasInlineMode; - content?: string; - url?: string; - title?: string; - streaming?: boolean; - eventId?: string; -} - /** * Dispatch a canvas-inline-event from a `render_inline_canvas` tool_call's * args object. Reading from args (not the tool_result string) guarantees the @@ -210,7 +219,7 @@ function dispatchCanvasInlineEventFromArgs( toolCallId: string ): void { const mode = isCanvasInlineMode(args.mode) ? args.mode : "html"; - const payload: CanvasInlineDispatchPayload = { + const payload: CanvasInlinePayload = { mode, content: typeof args.content === "string" ? args.content : undefined, url: typeof args.url === "string" ? args.url : undefined, @@ -260,6 +269,18 @@ export async function handleInteractionFinalized( ...resultObject, }; await eventStoreProxy.mergeEvents([resultEvent], sessionId); + + if (isAutoModeSwitchAccept(event.tool, resultObject)) { + const targetMode = + typeof resultObject.targetMode === "string" + ? resultObject.targetMode + : "plan"; + await switchModeForSession( + sessionId, + `tool-call-${toolCallId}`, + targetMode + ); + } } export { diff --git a/src/engines/SessionCore/sync/adapters/rustAgent/eventHandlers/types.ts b/src/engines/SessionCore/sync/adapters/rustAgent/eventHandlers/types.ts index 4387cd3c8..d29abf7e0 100644 --- a/src/engines/SessionCore/sync/adapters/rustAgent/eventHandlers/types.ts +++ b/src/engines/SessionCore/sync/adapters/rustAgent/eventHandlers/types.ts @@ -61,6 +61,7 @@ export interface EventHandlerContext { onContextUsageRef: MutableRefObject< ((contextUsage: AgentContextUsageSnapshot) => void) | undefined >; + onTokenUpdateRef: MutableRefObject<((tokens: number) => void) | undefined>; onStatusChangeRef: MutableRefObject< | (( status: string, @@ -92,6 +93,7 @@ export interface EventHandlerContext { export interface EventHandlerCallbacksInternal { onAgentComplete?: (tokenUsage?: AgentTokenUsage) => void; onContextUsage?: (contextUsage: AgentContextUsageSnapshot) => void; + onTokenUpdate?: (tokens: number) => void; onStatusChange?: (status: string, errorMessage?: string) => void; onPermissionRequest?: (event: PermissionRequestEvent) => void; onQuestionRequest?: (event: QuestionRequestEvent) => void; diff --git a/src/engines/SessionCore/sync/adapters/rustAgent/toolUsageCache.ts b/src/engines/SessionCore/sync/adapters/rustAgent/toolUsageCache.ts new file mode 100644 index 000000000..bce624aca --- /dev/null +++ b/src/engines/SessionCore/sync/adapters/rustAgent/toolUsageCache.ts @@ -0,0 +1,272 @@ +import { + type LlmUsageSpanRecord, + TOOL_USAGE_ATTRIBUTION_METHOD, + type ToolUsageAttributionRecord, + getSessionLlmUsageSpans, + getSessionToolUsageAttributions, +} from "@src/api/tauri/session"; +import { + LLM_USAGE_ARGS_KEY, + type LlmUsageMetadata, + type SessionEvent, + TOOL_USAGE_ARGS_KEY, + type ToolUsageMetadata, +} from "@src/engines/SessionCore/core/types"; + +const MAX_SESSION_USAGE_CACHE_SIZE = 100; + +interface UsageTelemetryMaps { + toolUsageByCallId: Map; + llmUsageByTurnId: Map; +} + +const sessionUsageCache = new Map>(); + +function touchSessionCache( + sessionId: string, + usageByCallId: Map +): void { + if (sessionUsageCache.has(sessionId)) { + sessionUsageCache.delete(sessionId); + } + sessionUsageCache.set(sessionId, usageByCallId); + while (sessionUsageCache.size > MAX_SESSION_USAGE_CACHE_SIZE) { + const oldestKey = sessionUsageCache.keys().next().value; + if (!oldestKey) break; + sessionUsageCache.delete(oldestKey); + } +} + +interface CacheUsageTotals { + cacheReadTokens: number; + cacheWriteTokens: number; +} + +function parseRelatedToolCallIds(span: LlmUsageSpanRecord): string[] { + if (!span.relatedToolCallIdsJson) return []; + const parsed: unknown = JSON.parse(span.relatedToolCallIdsJson); + if (!Array.isArray(parsed)) return []; + return parsed.filter((value): value is string => typeof value === "string"); +} + +function buildRelatedCacheMap( + spans: readonly LlmUsageSpanRecord[] +): Map { + const cacheByCallId = new Map(); + for (const span of spans) { + const relatedToolCallIds = parseRelatedToolCallIds(span); + if (relatedToolCallIds.length === 0) continue; + for (const toolCallId of relatedToolCallIds) { + const existing = cacheByCallId.get(toolCallId) ?? { + cacheReadTokens: 0, + cacheWriteTokens: 0, + }; + cacheByCallId.set(toolCallId, { + cacheReadTokens: existing.cacheReadTokens + span.cacheReadTokens, + cacheWriteTokens: existing.cacheWriteTokens + span.cacheWriteTokens, + }); + } + } + return cacheByCallId; +} + +function toMetadata( + record: ToolUsageAttributionRecord, + relatedCache?: CacheUsageTotals +): ToolUsageMetadata { + return { + decisionCompletionTokens: record.decisionCompletionTokens, + resultContextTokens: record.resultContextTokens, + followupCompletionTokens: record.followupCompletionTokens, + inputBytes: record.inputBytes, + outputBytes: record.outputBytes, + relatedCacheReadTokens: relatedCache?.cacheReadTokens ?? 0, + relatedCacheWriteTokens: relatedCache?.cacheWriteTokens ?? 0, + attributionMethod: record.attributionMethod, + }; +} + +export function buildUsageMap( + records: readonly ToolUsageAttributionRecord[], + spans: readonly LlmUsageSpanRecord[] = [] +): Map { + const cacheByCallId = buildRelatedCacheMap(spans); + const usageByCallId = new Map(); + for (const record of records) { + const existing = usageByCallId.get(record.toolCallId); + if (!existing) { + usageByCallId.set( + record.toolCallId, + toMetadata(record, cacheByCallId.get(record.toolCallId)) + ); + continue; + } + usageByCallId.set(record.toolCallId, { + decisionCompletionTokens: + existing.decisionCompletionTokens + record.decisionCompletionTokens, + resultContextTokens: + existing.resultContextTokens + record.resultContextTokens, + followupCompletionTokens: + existing.followupCompletionTokens + record.followupCompletionTokens, + inputBytes: existing.inputBytes + record.inputBytes, + outputBytes: existing.outputBytes + record.outputBytes, + relatedCacheReadTokens: existing.relatedCacheReadTokens, + relatedCacheWriteTokens: existing.relatedCacheWriteTokens, + attributionMethod: + existing.attributionMethod === record.attributionMethod + ? existing.attributionMethod + : record.attributionMethod, + }); + } + for (const record of records) { + const usage = usageByCallId.get(record.toolCallId); + if (usage) usageByCallId.set(record.eventId, usage); + } + return usageByCallId; +} + +export function buildLlmUsageByTurnMap( + spans: readonly LlmUsageSpanRecord[] +): Map { + const usageByTurnId = new Map(); + for (const span of spans) { + if (parseRelatedToolCallIds(span).length > 0) continue; + const existing = usageByTurnId.get(span.turnId); + usageByTurnId.set(span.turnId, { + inputTokens: (existing?.inputTokens ?? 0) + span.promptTokens, + outputTokens: (existing?.outputTokens ?? 0) + span.completionTokens, + cacheReadTokens: (existing?.cacheReadTokens ?? 0) + span.cacheReadTokens, + cacheWriteTokens: + (existing?.cacheWriteTokens ?? 0) + span.cacheWriteTokens, + model: existing?.model ?? span.model, + attributionMethod: TOOL_USAGE_ATTRIBUTION_METHOD.PROVIDER_EXACT, + }); + } + return usageByTurnId; +} + +export function withToolUsageArgs( + args: Record, + toolUsage: ToolUsageMetadata +): Record { + return { + ...args, + [TOOL_USAGE_ARGS_KEY]: toolUsage, + }; +} + +export function withLlmUsageArgs( + args: Record, + llmUsage: LlmUsageMetadata +): Record { + return { + ...args, + [LLM_USAGE_ARGS_KEY]: llmUsage, + }; +} + +function eventTurnId(event: SessionEvent): string | null { + const turnId = event.args?.turnId; + return typeof turnId === "string" ? turnId : null; +} + +function isAssistantMessageEvent(event: SessionEvent): boolean { + return ( + event.source === "assistant" && + event.displayVariant === "message" && + event.functionName !== "turn_summary" + ); +} + +function isThinkingEvent(event: SessionEvent): boolean { + return ( + event.displayVariant === "thinking" || event.uiCanonical === "thinking" + ); +} + +function selectLlmUsageTargetEvents( + events: readonly SessionEvent[] +): Map { + const targetsByTurnId = new Map(); + for (const event of events) { + const turnId = eventTurnId(event); + if (!turnId) continue; + if (isAssistantMessageEvent(event)) { + targetsByTurnId.set(turnId, event); + continue; + } + if (isThinkingEvent(event) && !targetsByTurnId.has(turnId)) { + targetsByTurnId.set(turnId, event); + } + } + return targetsByTurnId; +} + +export function applyLlmUsageToEvents( + events: readonly SessionEvent[], + usageByTurnId: ReadonlyMap +): SessionEvent[] { + if (usageByTurnId.size === 0) return [...events]; + const targetEvents = selectLlmUsageTargetEvents(events); + const targetIds = new Set( + [...targetEvents.values()].map((event) => event.id) + ); + return events.map((event) => { + if (!targetIds.has(event.id)) return event; + const turnId = eventTurnId(event); + const llmUsage = turnId ? usageByTurnId.get(turnId) : undefined; + if (!llmUsage) return event; + return { + ...event, + args: withLlmUsageArgs(event.args, llmUsage), + llmUsage, + }; + }); +} + +export function applyToolUsageToEvents( + events: readonly SessionEvent[], + usageByCallId: ReadonlyMap +): SessionEvent[] { + if (usageByCallId.size === 0) return [...events]; + return events.map((event) => { + const toolUsage = event.callId + ? (usageByCallId.get(event.callId) ?? usageByCallId.get(event.id)) + : usageByCallId.get(event.id); + if (!toolUsage) return event; + return { + ...event, + args: withToolUsageArgs(event.args, toolUsage), + toolUsage, + }; + }); +} + +export async function loadUsageTelemetry( + sessionId: string +): Promise { + const [records, spans] = await Promise.all([ + getSessionToolUsageAttributions(sessionId), + getSessionLlmUsageSpans(sessionId), + ]); + const toolUsageByCallId = buildUsageMap(records, spans); + const llmUsageByTurnId = buildLlmUsageByTurnMap(spans); + touchSessionCache(sessionId, toolUsageByCallId); + return { toolUsageByCallId, llmUsageByTurnId }; +} + +export async function loadAndCacheToolUsage( + sessionId: string +): Promise> { + const { toolUsageByCallId } = await loadUsageTelemetry(sessionId); + return toolUsageByCallId; +} + +export function getCachedToolUsage( + sessionId: string +): Map | undefined { + const cached = sessionUsageCache.get(sessionId); + if (!cached) return undefined; + touchSessionCache(sessionId, cached); + return cached; +} diff --git a/src/engines/SessionCore/sync/adapters/shared/__tests__/subagentTracking.test.ts b/src/engines/SessionCore/sync/adapters/shared/__tests__/subagentTracking.test.ts new file mode 100644 index 000000000..003c4d352 --- /dev/null +++ b/src/engines/SessionCore/sync/adapters/shared/__tests__/subagentTracking.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; + +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; + +import { + SPAWNED_SESSION_RE, + findSubagentParentEventId, +} from "../subagentTracking"; + +function toolCallEvent(overrides: Partial): SessionEvent { + return { + id: "evt-1", + sessionId: "parent-session", + type: "tool_call", + actionType: "tool_call", + functionName: "agent", + args: {}, + result: null, + content: "", + timestamp: "2026-07-02T19:52:13Z", + createdAt: "2026-07-02T19:52:13Z", + ...overrides, + } as SessionEvent; +} + +describe("SPAWNED_SESSION_RE", () => { + it("matches Rust-native agent session ids with agent ids containing colons", () => { + const sessionId = + "agent-builtin:general-0cfe485e-7f20-4158-9f0e-7d8eea3de2c9"; + + expect(`Session ID: ${sessionId}`.match(SPAWNED_SESSION_RE)?.[0]).toBe( + sessionId + ); + }); +}); + +describe("findSubagentParentEventId", () => { + it("finds the parent agent tool call whose result contains the child session id", () => { + const sessionId = + "agent-builtin:general-0cfe485e-7f20-4158-9f0e-7d8eea3de2c9"; + const events = [ + toolCallEvent({ id: "unrelated", result: { content: "no child here" } }), + toolCallEvent({ + id: "parent-agent-call", + result: { content: `Subagent launched. Session ID: ${sessionId}` }, + }), + ]; + + expect(findSubagentParentEventId(events, sessionId)).toBe( + "parent-agent-call" + ); + }); +}); diff --git a/src/engines/SessionCore/sync/adapters/shared/subagentTracking.ts b/src/engines/SessionCore/sync/adapters/shared/subagentTracking.ts index fde0319d9..c56ad781f 100644 --- a/src/engines/SessionCore/sync/adapters/shared/subagentTracking.ts +++ b/src/engines/SessionCore/sync/adapters/shared/subagentTracking.ts @@ -35,7 +35,8 @@ export const SPAWNING_TOOLS_ARRAY = [ "subagent", ]; -export const SPAWNED_SESSION_RE = /(?:agentsession|subagent)-[a-f0-9-]+/; +export const SPAWNED_SESSION_RE = + /(?:agentsession|subagent|agent-[A-Za-z0-9:_-]+)-[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/; /** * Find the most recent subagent-spawning tool_call that hasn't received diff --git a/src/engines/SessionCore/sync/adapters/shared/types.ts b/src/engines/SessionCore/sync/adapters/shared/types.ts index 99f9a146b..1c2677dd0 100644 --- a/src/engines/SessionCore/sync/adapters/shared/types.ts +++ b/src/engines/SessionCore/sync/adapters/shared/types.ts @@ -309,6 +309,15 @@ export interface PermissionRequestEvent { toolCallId?: string; args: Record; agentType?: RustAgentType; + /** + * Where the pending approval is parked on the backend. Default + * (undefined) is the Rust-agent `AgentPermissionManager` + * (`agent_permission_response`). `"cli_hook"` marks a managed CLI + * session's PermissionRequest hook long-poll, `"acp"` an ACP agent's + * (OpenCode/Copilot/Kiro) parked `session/request_permission` — both + * answered via `cli_agent_approval_response` instead. + */ + origin?: "cli_hook" | "acp"; } export interface QuestionRequestEvent { @@ -332,6 +341,7 @@ export interface PlanReadyForApprovalEvent { planId?: string; planRevisionId?: string; originToolCallId?: string; + autoApproveAt?: number | null; } /** diff --git a/src/engines/SessionCore/sync/externalHistoryAutoRefresh.test.ts b/src/engines/SessionCore/sync/externalHistoryAutoRefresh.test.ts new file mode 100644 index 000000000..053a3124d --- /dev/null +++ b/src/engines/SessionCore/sync/externalHistoryAutoRefresh.test.ts @@ -0,0 +1,64 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { refreshImportedHistorySession } from "./externalHistoryAutoRefresh"; + +const mocks = vi.hoisted(() => ({ + loadHistory: vi.fn(), + getAdapterForSession: vi.fn(), +})); + +vi.mock("./types", () => ({ + getAdapterForSession: mocks.getAdapterForSession, +})); + +describe("refreshImportedHistorySession", () => { + beforeEach(() => { + mocks.loadHistory.mockReset(); + mocks.getAdapterForSession.mockReset().mockReturnValue({ + category: "external_history", + loadHistory: mocks.loadHistory, + }); + }); + + it("reloads and publishes the currently open external transcript", async () => { + const events = [ + { + id: "event-1", + sessionId: "codexapp-active", + createdAt: "2026-07-16T05:00:00.000Z", + }, + ]; + mocks.loadHistory.mockResolvedValue(events); + const dispatchLoadSession = vi.fn(); + const controller = new AbortController(); + + await expect( + refreshImportedHistorySession( + "codexapp-active", + controller.signal, + dispatchLoadSession + ) + ).resolves.toBe(true); + + expect(mocks.loadHistory).toHaveBeenCalledWith( + "codexapp-active", + controller.signal + ); + expect(dispatchLoadSession).toHaveBeenCalledWith({ + sessionId: "codexapp-active", + events, + }); + }); + + it("does not poll a native ORGII session", async () => { + await expect( + refreshImportedHistorySession( + "osagent-native", + new AbortController().signal, + vi.fn() + ) + ).resolves.toBe(false); + + expect(mocks.getAdapterForSession).not.toHaveBeenCalled(); + }); +}); diff --git a/src/engines/SessionCore/sync/externalHistoryAutoRefresh.ts b/src/engines/SessionCore/sync/externalHistoryAutoRefresh.ts new file mode 100644 index 000000000..04e570317 --- /dev/null +++ b/src/engines/SessionCore/sync/externalHistoryAutoRefresh.ts @@ -0,0 +1,142 @@ +import { useAtomValue } from "jotai"; +import { useEffect } from "react"; + +import { getImportedHistorySourceBySessionId } from "@src/api/tauri/externalHistory"; +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; +import { createLogger } from "@src/hooks/logger"; +import { externalSessionsEnabledAtom } from "@src/store/session/dataSourceConfigAtom"; +import { + isWindowFocused, + onWindowFocusRegained, +} from "@src/util/core/windowFocus"; +import { isImportedHistorySession } from "@src/util/session/sessionDispatch"; + +import { getAdapterForSession } from "./types"; + +const logger = createLogger("ExternalHistoryAutoRefresh"); + +// Refresh floor while the window is unfocused; the configured 3s-1m cadence +// only applies to a chat someone is looking at. +const UNFOCUSED_REFRESH_INTERVAL_MS = 60_000; + +type DispatchSessionLoad = (payload: { + sessionId: string; + events: SessionEvent[]; +}) => void; + +// Last observed transcript signature (`mtime:size`) per session. Refresh +// ticks compare a cheap backend `stat` against this and skip the full +// read → parse → merge pipeline while the file is unchanged — which is +// every tick for a finished session. Bounded: replays touch few sessions. +const transcriptSignatures = new Map(); +const MAX_TRANSCRIPT_SIGNATURES = 64; + +function rememberTranscriptSignature(sessionId: string, signature: string) { + if ( + !transcriptSignatures.has(sessionId) && + transcriptSignatures.size >= MAX_TRANSCRIPT_SIGNATURES + ) { + transcriptSignatures.clear(); + } + transcriptSignatures.set(sessionId, signature); +} + +/** + * Incremental guard: probe the transcript's (mtime, size) and report whether + * a full reload is needed. Errs on the side of reloading (stat unsupported + * for the source, file missing, probe failed). + */ +async function transcriptChanged( + sessionId: string, + signal: AbortSignal +): Promise<{ changed: boolean; signature: string | null }> { + const source = getImportedHistorySourceBySessionId(sessionId); + if (!source?.statTranscript) return { changed: true, signature: null }; + try { + const stat = await source.statTranscript(sessionId); + if (signal.aborted || !stat) return { changed: true, signature: null }; + const signature = `${stat.mtimeMs}:${stat.sizeBytes}`; + return { + changed: transcriptSignatures.get(sessionId) !== signature, + signature, + }; + } catch { + return { changed: true, signature: null }; + } +} + +/** Re-read one imported transcript without rescanning every provider cache. */ +export async function refreshImportedHistorySession( + sessionId: string, + signal: AbortSignal, + dispatchLoadSession: DispatchSessionLoad +): Promise { + if (!isImportedHistorySession(sessionId)) return false; + const adapter = getAdapterForSession(sessionId); + if (!adapter || adapter.category !== "external_history") return false; + + const { changed, signature } = await transcriptChanged(sessionId, signal); + if (!changed || signal.aborted) return false; + + const events = await adapter.loadHistory(sessionId, signal); + if (signal.aborted || events.length === 0) return false; + dispatchLoadSession({ sessionId, events }); + if (signature) rememberTranscriptSignature(sessionId, signature); + return true; +} + +export function useExternalHistoryAutoRefresh(options: { + sessionId: string | null; + intervalMs: number; + dispatchLoadSession: DispatchSessionLoad; +}): void { + const { sessionId, intervalMs, dispatchLoadSession } = options; + const externalSessionsEnabled = useAtomValue(externalSessionsEnabledAtom); + + useEffect(() => { + if (!externalSessionsEnabled) return; + if (!sessionId || !isImportedHistorySession(sessionId)) return; + + let refreshRunning = false; + let activeController: AbortController | null = null; + let lastAttemptAt = 0; + const refresh = async () => { + if (refreshRunning) return; + // The configured cadence (3s-1m) is for a chat the user is actually + // watching. While the window is unfocused, hold refreshes to one per + // minute (mirrors the backend git poller's focus-adaptive polling); + // regaining focus refreshes immediately via the listener below. + if ( + !isWindowFocused() && + Date.now() - lastAttemptAt < UNFOCUSED_REFRESH_INTERVAL_MS + ) { + return; + } + lastAttemptAt = Date.now(); + refreshRunning = true; + activeController = new AbortController(); + try { + await refreshImportedHistorySession( + sessionId, + activeController.signal, + dispatchLoadSession + ); + } catch (error) { + if (!activeController.signal.aborted) { + logger.warn(`Failed to refresh ${sessionId}:`, error); + } + } finally { + refreshRunning = false; + activeController = null; + } + }; + + const intervalId = window.setInterval(() => void refresh(), intervalMs); + const unsubscribeFocus = onWindowFocusRegained(() => void refresh()); + return () => { + window.clearInterval(intervalId); + unsubscribeFocus(); + activeController?.abort(); + }; + }, [dispatchLoadSession, externalSessionsEnabled, intervalMs, sessionId]); +} diff --git a/src/engines/SessionCore/sync/nativeTranscriptReconcile.ts b/src/engines/SessionCore/sync/nativeTranscriptReconcile.ts new file mode 100644 index 000000000..4c7ef8f2b --- /dev/null +++ b/src/engines/SessionCore/sync/nativeTranscriptReconcile.ts @@ -0,0 +1,94 @@ +/** + * Post-turn reconcile for native-transcript CLI sessions. + * + * Native-mode sessions stream ephemeral (in-memory only) events during a + * turn; the transcript of record is the CLI's own store, read back through + * `cli_agent_chunks` (which routes to the imported-history loaders). When a + * turn reaches a terminal status we reload once after a short settle delay + * so the in-memory events are replaced by the canonical parse, and retry + * once more in case the CLI flushed its store slightly after exiting. + * + * The registry is populated by the CLI adapter's postLoad (from + * `cli_agent_status.transcriptSource`); legacy sessions never reconcile. + */ +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; + +const transcriptSourceBySession = new Map(); + +const RECONCILE_SETTLE_MS = 600; +const RECONCILE_RETRY_MS = 2000; + +export function registerSessionTranscriptSource( + sessionId: string, + transcriptSource: string | undefined +): void { + if (transcriptSource) { + transcriptSourceBySession.set(sessionId, transcriptSource); + } +} + +export function isNativeTranscriptSession(sessionId: string): boolean { + return transcriptSourceBySession.get(sessionId) === "native"; +} + +interface ReconcileDeps { + loadHistory: (sessionId: string) => Promise; + dispatchLoadSession: (payload: { + sessionId: string; + events: SessionEvent[]; + /** + * The native replay IS the canonical transcript: loadSessionAtom must + * replace the in-memory turn events (synthetic user bubble, streamed + * placeholders) instead of merging next to them — their ids never match + * the replayed rows, so a merge renders every turn twice. + */ + replace?: boolean; + }) => void; + /** The session still on screen? Stale reconciles are dropped. */ + isSessionLive: (sessionId: string) => boolean; +} + +const pendingReconciles = new Set(); + +export function scheduleNativeTranscriptReconcile( + sessionId: string, + deps: ReconcileDeps +): void { + if (!isNativeTranscriptSession(sessionId)) return; + if (pendingReconciles.has(sessionId)) return; + pendingReconciles.add(sessionId); + + const runOnce = async (): Promise => { + if (!deps.isSessionLive(sessionId)) return -1; + const events = await deps.loadHistory(sessionId); + if (!deps.isSessionLive(sessionId)) return -1; + if (events.length > 0) { + deps.dispatchLoadSession({ sessionId, events, replace: true }); + } + return events.length; + }; + + void (async () => { + try { + await new Promise((resolve) => setTimeout(resolve, RECONCILE_SETTLE_MS)); + const firstCount = await runOnce(); + if (firstCount < 0) return; + // One retry catches a store flushed slightly after process exit; only + // re-dispatch when the parse actually grew (no pointless flicker). + await new Promise((resolve) => setTimeout(resolve, RECONCILE_RETRY_MS)); + if (!deps.isSessionLive(sessionId)) return; + const events = await deps.loadHistory(sessionId); + if ( + events.length > Math.max(firstCount, 0) && + deps.isSessionLive(sessionId) + ) { + deps.dispatchLoadSession({ sessionId, events, replace: true }); + } + } catch { + // Best-effort: the ephemeral in-memory events remain on screen; the + // next session open replays from the native store anyway. + } finally { + pendingReconciles.delete(sessionId); + } + })(); +} diff --git a/src/engines/SessionCore/sync/sessionChannelActivity.test.ts b/src/engines/SessionCore/sync/sessionChannelActivity.test.ts new file mode 100644 index 000000000..e1e267fc2 --- /dev/null +++ b/src/engines/SessionCore/sync/sessionChannelActivity.test.ts @@ -0,0 +1,56 @@ +/** + * sessionChannelActivity — liveness stamp regression tests. + * + * Regression target: the planning watchdog force-completed sessions whose + * turns streamed only ephemeral events (tool_call_delta never bumps the + * EventStore version), because "activity" was defined as store mutations. + * These tests pin the channel-activity source the watchdog now consults. + */ +import { afterEach, describe, expect, it } from "vitest"; + +import { + clearSessionChannelActivity, + msSinceSessionChannelActivity, + noteSessionChannelActivity, +} from "./sessionChannelActivity"; + +afterEach(() => { + clearSessionChannelActivity(); +}); + +describe("sessionChannelActivity", () => { + it("returns null for a session with no observed events", () => { + expect(msSinceSessionChannelActivity("s-unknown")).toBeNull(); + }); + + it("measures recency from the latest stamp", () => { + noteSessionChannelActivity("s1", 1_000); + expect(msSinceSessionChannelActivity("s1", 4_500)).toBe(3_500); + }); + + it("newer stamps overwrite older ones", () => { + noteSessionChannelActivity("s1", 1_000); + noteSessionChannelActivity("s1", 10_000); + expect(msSinceSessionChannelActivity("s1", 11_000)).toBe(1_000); + }); + + it("tracks sessions independently", () => { + noteSessionChannelActivity("s1", 1_000); + noteSessionChannelActivity("s2", 5_000); + expect(msSinceSessionChannelActivity("s1", 6_000)).toBe(5_000); + expect(msSinceSessionChannelActivity("s2", 6_000)).toBe(1_000); + }); + + it("floors clock skew at zero", () => { + noteSessionChannelActivity("s1", 10_000); + expect(msSinceSessionChannelActivity("s1", 9_000)).toBe(0); + }); + + it("clears one session without touching others", () => { + noteSessionChannelActivity("s1", 1_000); + noteSessionChannelActivity("s2", 1_000); + clearSessionChannelActivity("s1"); + expect(msSinceSessionChannelActivity("s1")).toBeNull(); + expect(msSinceSessionChannelActivity("s2", 2_000)).toBe(1_000); + }); +}); diff --git a/src/engines/SessionCore/sync/sessionChannelActivity.ts b/src/engines/SessionCore/sync/sessionChannelActivity.ts new file mode 100644 index 000000000..2436fab31 --- /dev/null +++ b/src/engines/SessionCore/sync/sessionChannelActivity.ts @@ -0,0 +1,49 @@ +/** + * Session Channel Activity + * + * Last-seen timestamp of ANY event arriving on a session's per-session IPC + * channel. This is the single liveness source for "is the backend still + * feeding us events", independent of whether an event mutates the EventStore. + * + * Why not the EventStore `version`: several event classes are deliberately + * ephemeral and never bump it — `agent:tool_call_delta` buffers in memory + * only (see streamHandlers.ts), `agent:stream_retry` writes a side atom, etc. + * A turn that spends minutes streaming one large tool call therefore looks + * "dead" to any version-based watchdog while the channel is in fact busy. + * + * Deliberately NOT reactive (plain Map, no jotai atom): deltas arrive at + * token frequency and must not trigger React re-renders. Consumers with a + * deadline (planning watchdog) check recency when their timer fires and + * re-arm for the remainder instead of subscribing. + */ + +const lastChannelActivityBySession = new Map(); + +/** Stamp channel activity for a session. Called once per received event. */ +export function noteSessionChannelActivity( + sessionId: string, + now: number = Date.now() +): void { + lastChannelActivityBySession.set(sessionId, now); +} + +/** + * Milliseconds since the last channel event for the session, or `null` when + * no event has been observed since app start (cold session, never subscribed). + */ +export function msSinceSessionChannelActivity( + sessionId: string, + now: number = Date.now() +): number | null { + const last = lastChannelActivityBySession.get(sessionId); + return last === undefined ? null : Math.max(0, now - last); +} + +/** Test-only reset so specs don't leak activity across cases. */ +export function clearSessionChannelActivity(sessionId?: string): void { + if (sessionId === undefined) { + lastChannelActivityBySession.clear(); + return; + } + lastChannelActivityBySession.delete(sessionId); +} diff --git a/src/engines/SessionCore/sync/sessionSwitchOrchestrator.ts b/src/engines/SessionCore/sync/sessionSwitchOrchestrator.ts index 98d5d03fe..b285e93e7 100644 --- a/src/engines/SessionCore/sync/sessionSwitchOrchestrator.ts +++ b/src/engines/SessionCore/sync/sessionSwitchOrchestrator.ts @@ -1,4 +1,4 @@ -import { cursorBridgeComposerLastUpdatedAt } from "@src/api/tauri/cursorBridge"; +import { cursorIdeComposerLastUpdatedAt } from "@src/api/tauri/externalHistory/cursorIde"; import { Message } from "@src/components/Message"; import { eventStoreProxy } from "@src/engines/SessionCore/core/store/EventStoreProxy"; import { isVisibleInChat } from "@src/engines/SessionCore/ingestion/visibilityFilters"; @@ -20,7 +20,6 @@ import type { SessionSyncRefs } from "./sessionSyncTypes"; import { hydrateSessionStoreBeforeDisplay, isInFlightRunStatus, - loadOwnSessionInitialEvents, loadPersistedHistory, } from "./sessionSyncUtils"; import type { SessionAdapter } from "./types"; @@ -144,7 +143,11 @@ async function handleCacheHit( // initial load (which itself falls back to `loadEvents` when the turn // index is empty) and re-hydrate the store so the events actually show. if (displayEvents.length === 0 || !displayEvents.some(isVisibleInChat)) { - const fallbackEvents = await loadOwnSessionInitialEvents(sessionId); + const fallbackEvents = await loadPersistedHistory( + adapter, + sessionId, + abortController.signal + ); if (abortController.signal.aborted) return; if (fallbackEvents.length > 0) { await hydrateSessionStoreBeforeDisplay(sessionId, fallbackEvents); @@ -195,7 +198,7 @@ async function handleCursorIdeCacheHit( actions.setLoadStatus("loading"); const composerId = composerIdFromSessionId(sessionId); const currentUpdatedAt = composerId - ? await cursorBridgeComposerLastUpdatedAt(composerId) + ? await cursorIdeComposerLastUpdatedAt(composerId) : null; if (abortController.signal.aborted) return true; const cachedUpdatedAt = getCursorIdeSnapshotLastUpdatedAt(sessionId); diff --git a/src/engines/SessionCore/sync/sessionSyncPlanApproval.ts b/src/engines/SessionCore/sync/sessionSyncPlanApproval.ts index c0702b6c0..95a61f0ea 100644 --- a/src/engines/SessionCore/sync/sessionSyncPlanApproval.ts +++ b/src/engines/SessionCore/sync/sessionSyncPlanApproval.ts @@ -1,7 +1,7 @@ import { getPendingPlanApproval } from "@src/api/tauri/agent"; import { type PlanApprovalStateMap, - upsertPendingPlanApproval, + rehydratePendingPlanApprovalIfNewer, } from "@src/store/session/planApprovalAtom"; export function rehydratePendingPlanApproval( @@ -14,9 +14,12 @@ export function rehydratePendingPlanApproval( const rehydrate = async () => { try { const snapshot = await getPendingPlanApproval(sessionId); + // Guard: abort signal may have fired while the RPC was in flight. if (abortController.signal.aborted || !snapshot) return; + // Use the revision-aware merge to avoid clobbering a live + // plan_ready_for_approval push that arrived before this RPC resolved. setPendingPlanApprovals((prev) => - upsertPendingPlanApproval(prev, snapshot) + rehydratePendingPlanApprovalIfNewer(prev, snapshot) ); } catch { // Non-critical: the Build button stays disabled until Rust broadcasts diff --git a/src/engines/SessionCore/sync/sessionSyncReconcile.ts b/src/engines/SessionCore/sync/sessionSyncReconcile.ts index b4d729002..6abab66db 100644 --- a/src/engines/SessionCore/sync/sessionSyncReconcile.ts +++ b/src/engines/SessionCore/sync/sessionSyncReconcile.ts @@ -1,5 +1,7 @@ +import { eventStoreProxy } from "@src/engines/SessionCore/core/store/EventStoreProxy"; import { type SessionStatus, updateSessionStatus } from "@src/store/session"; +import { isNativeTranscriptSession } from "./nativeTranscriptReconcile"; import { type SessionLoadStateActions, applyPostLoadResult, @@ -15,17 +17,25 @@ import { } from "./sessionSyncUtils"; import type { SessionAdapter } from "./types"; +type PostLoadStateActions = Pick< + SessionLoadStateActions, + | "setSessionContextTokens" + | "setSessionContextUsage" + | "setSessionRuntimeStatus" + | "setSessionRuntimeError" +>; + +type ReconcileStateActions = Pick< + SessionLoadStateActions, + "dispatchLoadSession" +> & + PostLoadStateActions; + export function reconcileInFlightHistory( sessionId: string, adapter: SessionAdapter, refs: Pick, - actions: Pick< - SessionLoadStateActions, - | "dispatchLoadSession" - | "setSessionContextTokens" - | "setSessionRuntimeStatus" - | "setSessionRuntimeError" - > + actions: ReconcileStateActions ): void { const reconcile = async () => { const reconcileController = new AbortController(); @@ -50,17 +60,46 @@ export function reconcileInFlightHistory( continue; } - await hydrateSessionStoreBeforeDisplay( - sessionId, - persistedEvents, - "merge" - ); - if (refs.liveSessionIdRef.current !== sessionId) return; - actions.dispatchLoadSession({ sessionId, events: persistedEvents }); + // Native-transcript CLI sessions: the live turn renders from in-memory + // events only (optimistic user bubble + streamed broadcasts). Repeated + // replay merges here were the "~5s" duplicate-bubble source — each tick + // parked replay rows (synthesized fallback, then the real + // `codex-user-N` parse) next to them under never-matching ids. Only an + // EMPTY store (switched into a still-running session after a restart or + // eviction) is hydrated, with replace semantics so a retry tick stays + // idempotent; the terminal reconcile owns the final canonical replace. + if (isNativeTranscriptSession(sessionId)) { + const existingEvents = await eventStoreProxy.getEvents(sessionId); + if (refs.liveSessionIdRef.current !== sessionId) return; + if (existingEvents.length === 0) { + await hydrateSessionStoreBeforeDisplay( + sessionId, + persistedEvents, + "replace" + ); + if (refs.liveSessionIdRef.current !== sessionId) return; + actions.dispatchLoadSession({ + sessionId, + events: persistedEvents, + replace: true, + }); + } + } else { + await hydrateSessionStoreBeforeDisplay( + sessionId, + persistedEvents, + "merge" + ); + if (refs.liveSessionIdRef.current !== sessionId) return; + actions.dispatchLoadSession({ sessionId, events: persistedEvents }); + } if (postResult?.contextTokens !== undefined) { actions.setSessionContextTokens(postResult.contextTokens); } + if (postResult?.contextUsage !== undefined) { + actions.setSessionContextUsage(postResult.contextUsage); + } if (postResult?.runStatus !== undefined) { actions.setSessionRuntimeStatus( toCliSessionStatus(postResult.runStatus) @@ -80,12 +119,7 @@ export function reconcileInFlightHistory( export function applySwitchPostLoadResult( sessionId: string, postResult: Parameters[1], - actions: Pick< - SessionLoadStateActions, - | "setSessionContextTokens" - | "setSessionRuntimeStatus" - | "setSessionRuntimeError" - > + actions: PostLoadStateActions ): void { applyPostLoadResult(sessionId, postResult, actions); } diff --git a/src/engines/SessionCore/sync/sessionSyncStateHelpers.ts b/src/engines/SessionCore/sync/sessionSyncStateHelpers.ts index 775cb6381..296890838 100644 --- a/src/engines/SessionCore/sync/sessionSyncStateHelpers.ts +++ b/src/engines/SessionCore/sync/sessionSyncStateHelpers.ts @@ -6,6 +6,11 @@ import { markTurnTerminal, toTurnTerminalStatus, } from "@src/engines/SessionCore/control/turnLifecycle"; +import type { StreamingDeltaContent } from "@src/engines/SessionCore/core/atoms/events"; +import { + bufferStreamingDelta, + clearStreamingDelta, +} from "@src/engines/SessionCore/core/atoms/streamingDeltaBuffer"; import { eventStoreProxy } from "@src/engines/SessionCore/core/store/EventStoreProxy"; import type { SessionEvent, @@ -30,6 +35,13 @@ type LoadSessionPayload = { sessionId: string; events: SessionEvent[]; isFromCache?: boolean; + /** + * The incoming events ARE the canonical transcript — replace the on-screen + * events instead of id-merging next to them (see loadSessionAtom). Used by + * native-transcript replay loads, whose replayed ids never match the + * ephemeral in-memory turn events. + */ + replace?: boolean; }; export interface SessionSwitchStateActions { @@ -65,8 +77,16 @@ export interface SessionEventHandlerStateActions { setPendingCancel: (value: boolean) => void; setSessionRolledBack: (value: boolean) => void; setStreamingDeltaContent: ( - update: SetStateAction> + update: SetStateAction> ) => void; + /** Dismiss any existing canvas preview when a new agent turn starts. */ + dismissCanvasAtNewTurn: (sessionId: string) => void; + /** + * Replace the turn's ephemeral in-memory events with the canonical + * native-store parse once a terminal status lands. No-op for legacy + * (chunk-persisted) sessions. + */ + scheduleNativeTranscriptReconcile?: (sessionId: string) => void; } const TERMINAL_HANDLER_STATUSES = new Set([ @@ -140,20 +160,29 @@ export function applyPostLoadResult( } } +/** + * Per-chunk writes go through the streaming delta buffer: chunks land in a + * module-level holder synchronously and reach the atom on a trailing ~50ms + * flush (immediate on stream start, kind change, and completion) — see + * streamingDeltaBuffer.ts for the flush guarantees. + */ export function updateStreamingDeltaContent( sessionId: string, info: StreamingDeltaInfo, setStreamingDeltaContent: SessionEventHandlerStateActions["setStreamingDeltaContent"] ): void { - setStreamingDeltaContent((prev) => { - const next = new Map(prev); - if (info.isStreaming && !info.isThinking) { - next.set(sessionId, info.content); - } else { - next.delete(sessionId); - } - return next; - }); + if (info.isStreaming) { + bufferStreamingDelta( + sessionId, + { + kind: info.isThinking ? "thinking" : "message", + content: info.content, + }, + setStreamingDeltaContent + ); + } else { + clearStreamingDelta(sessionId, setStreamingDeltaContent); + } } export function createSessionEventHandlerCallbacks( @@ -204,12 +233,18 @@ export function createSessionEventHandlerCallbacks( actions.setPendingCancel(false); eventStoreProxy.unpinSession(sessionId); updateSessionStatus(sessionId, status as SessionStatus); + actions.scheduleNativeTranscriptReconcile?.(sessionId); } if (status === "running") { markTurnRunning(sessionId); actions.setSessionRuntimeError(null); eventStoreProxy.pinSession(sessionId); actions.setSessionRolledBack(false); + // Dismiss any leftover canvas from a previous round. The new round's + // canvas will re-populate the atom only when render_inline_canvas is + // called. Without this, the streaming ChatVariant briefly shows a + // stale canvas from a prior turn at the start of each new turn. + actions.dismissCanvasAtNewTurn(sessionId); } }, onTokenUpdate: (tokens) => { diff --git a/src/engines/SessionCore/sync/sessionSyncUtils.ts b/src/engines/SessionCore/sync/sessionSyncUtils.ts index 52f796d83..b1ba5790a 100644 --- a/src/engines/SessionCore/sync/sessionSyncUtils.ts +++ b/src/engines/SessionCore/sync/sessionSyncUtils.ts @@ -117,7 +117,11 @@ export async function loadPersistedHistory( signal: AbortSignal ): Promise { if (adapter.category === "agent") { - return loadOwnSessionInitialEvents(sessionId); + const events = await loadOwnSessionInitialEvents(sessionId); + if (events.length > 0 || signal.aborted) { + return events; + } + return adapter.loadHistory(sessionId, signal); } return adapter.loadHistory(sessionId, signal); } diff --git a/src/engines/SessionCore/sync/types.ts b/src/engines/SessionCore/sync/types.ts index e9e413213..ad4e80c97 100644 --- a/src/engines/SessionCore/sync/types.ts +++ b/src/engines/SessionCore/sync/types.ts @@ -21,8 +21,7 @@ import type { ContextUsageSnapshot } from "@src/store/session/cliSessionStatusAt import { isAgentSession, isCliSession, - isCursorIdeSession, - isExternalHistorySession, + isImportedHistorySession, } from "@src/util/session/sessionDispatch"; import type { SessionEvent } from "../core/types"; @@ -321,10 +320,11 @@ export function getAdapterForSession( if (isCliSession(sessionId)) { return adapterRegistry.get("cli"); } - if (isCursorIdeSession(sessionId)) { - return adapterRegistry.get("cursor_ide"); - } - if (isExternalHistorySession(sessionId)) { + // Cursor IDE history carries a distinct `cursor_ide` display category (to + // separate imported IDE history from launched Cursor CLI sessions), but for + // *loading* it is read-only external history like the rest — route it to the + // same adapter. `isImportedHistorySession` = cursor_ide OR external_history. + if (isImportedHistorySession(sessionId)) { return adapterRegistry.get("external_history"); } return undefined; diff --git a/src/engines/SessionCore/sync/useSessionChannel.ts b/src/engines/SessionCore/sync/useSessionChannel.ts index 0750518e0..c34e4eb01 100644 --- a/src/engines/SessionCore/sync/useSessionChannel.ts +++ b/src/engines/SessionCore/sync/useSessionChannel.ts @@ -47,9 +47,49 @@ import { useEffect, useRef } from "react"; import { parseRawSessionEvent } from "@src/engines/SessionCore/core/schemas"; import { createLogger } from "@src/hooks/logger"; +import { recordPushEvent } from "@src/util/monitoring/apiTracker"; const log = createLogger("useSessionChannel"); +const readySessionChannels = new Set(); +const readySessionChannelWaiters = new Map void>>(); + +function markSessionChannelReady(sessionId: string): void { + readySessionChannels.add(sessionId); + const waiters = readySessionChannelWaiters.get(sessionId); + if (!waiters) return; + readySessionChannelWaiters.delete(sessionId); + for (const resolve of waiters) resolve(); +} + +/** + * Wait until the active SessionSyncProvider has registered the per-session IPC + * channel. New fork sessions can complete very quickly; dispatching before this + * edge can lose the terminal event and leave only the optimistic running state. + */ +export async function waitForSessionChannelReady( + sessionId: string, + timeoutMs = 5_000 +): Promise { + if (readySessionChannels.has(sessionId)) return; + await new Promise((resolve) => { + const waiters = readySessionChannelWaiters.get(sessionId) ?? new Set(); + waiters.add(resolve); + readySessionChannelWaiters.set(sessionId, waiters); + const timer = setTimeout(() => { + waiters.delete(resolve); + if (waiters.size === 0) readySessionChannelWaiters.delete(sessionId); + resolve(); + }, timeoutMs); + const wrappedResolve = () => { + clearTimeout(timer); + resolve(); + }; + waiters.delete(resolve); + waiters.add(wrappedResolve); + }); +} + export function validateSessionChannelMessage(message: string): string { parseRawSessionEvent(message); return message; @@ -230,18 +270,21 @@ export function useSessionChannel( ); channel.onmessage = (message: string) => { + recordPushEvent("channel", "session-events"); lifecycle.onMessage(message); }; - lifecycle.start(); + const subscription = lifecycle.start(); + void subscription.then((channelId) => { + if (channelId !== null && !lifecycle.isDestroyed()) { + markSessionChannelReady(sessionId); + } + }); return () => { - // Sever the message path eagerly so even if Tauri delivers more - // events between now and the unsubscribe IPC landing, the - // dispose flag inside `onMessage` is checked AND the closure - // identity can be GC'd once the Channel object is released. - // Replacing with a no-op (rather than `null`) avoids any - // "missing handler" warning Tauri may print in the future. + readySessionChannels.delete(sessionId); + // Sever the callback eagerly; lifecycle.dispose() guards late messages, + // and replacing the Tauri handler also releases the React closure. channel.onmessage = () => undefined; void lifecycle.dispose(); }; diff --git a/src/engines/SessionCore/sync/useSessionSync.ts b/src/engines/SessionCore/sync/useSessionSync.ts index 3e302c409..ac4d74f84 100644 --- a/src/engines/SessionCore/sync/useSessionSync.ts +++ b/src/engines/SessionCore/sync/useSessionSync.ts @@ -4,7 +4,7 @@ * Unified session sync hook replacing three divergent per-agent hooks. * Mounted ONCE in SessionSyncProvider (inside AppLayout). */ -import { useSetAtom } from "jotai"; +import { useAtomValue, useSetAtom } from "jotai"; import { useCallback, useEffect, useMemo, useRef } from "react"; import { @@ -16,6 +16,10 @@ import { streamingDeltaContentAtom, } from "@src/engines/SessionCore"; import { createLogger } from "@src/hooks/logger"; +import { + canvasPreviewAtom, + dismissCanvasForSession, +} from "@src/store/session/canvasPreviewAtom"; import { type CliSessionStatus, isPendingCancelAtom, @@ -27,10 +31,16 @@ import { setSessionRuntimeStatusAtom, streamRetryStatusAtom, } from "@src/store/session/cliSessionStatusAtom"; +import { + ACTIVE_EXTERNAL_SESSION_REFRESH_INTERVAL_MS, + activeExternalSessionRefreshFrequencyAtom, +} from "@src/store/session/dataSourceConfigAtom"; import { pendingPlanApprovalsAtom } from "@src/store/session/planApprovalAtom"; import { wpReadOnlyAtom } from "@src/store/ui/chatPanelAtom"; import "./adapters"; +import { useExternalHistoryAutoRefresh } from "./externalHistoryAutoRefresh"; +import { scheduleNativeTranscriptReconcile } from "./nativeTranscriptReconcile"; import { resetEmptySessionRefs, runSessionSwitchEffect, @@ -89,6 +99,18 @@ export function useSessionSync( const setStreamRetryStatus = useSetAtom(streamRetryStatusAtom); const setStreamingDeltaContent = useSetAtom(streamingDeltaContentAtom); const setPendingPlanApprovals = useSetAtom(pendingPlanApprovalsAtom); + const setCanvasPreview = useSetAtom(canvasPreviewAtom); + const activeExternalSessionRefreshFrequency = useAtomValue( + activeExternalSessionRefreshFrequencyAtom + ); + const dismissCanvasAtNewTurn = useCallback( + (sid: string) => { + setCanvasPreview((prev) => { + return dismissCanvasForSession(prev, sid); + }); + }, + [setCanvasPreview] + ); const adapterRef = useRef(null); const handlerRef = useRef(null); @@ -157,6 +179,22 @@ export function useSessionSync( ] ); + const scheduleReconcile = useCallback( + (sid: string) => { + scheduleNativeTranscriptReconcile(sid, { + loadHistory: async (target) => { + const adapter = getAdapterForSession(target); + if (!adapter) return []; + const controller = new AbortController(); + return adapter.loadHistory(target, controller.signal); + }, + dispatchLoadSession, + isSessionLive: (target) => liveSessionIdRef.current === target, + }); + }, + [dispatchLoadSession] + ); + const handlerActions = useMemo( () => ({ setSessionContextTokens, @@ -167,6 +205,8 @@ export function useSessionSync( setPendingCancel, setSessionRolledBack, setStreamingDeltaContent, + dismissCanvasAtNewTurn, + scheduleNativeTranscriptReconcile: scheduleReconcile, }), [ setSessionContextTokens, @@ -177,6 +217,8 @@ export function useSessionSync( setPendingCancel, setSessionRolledBack, setStreamingDeltaContent, + dismissCanvasAtNewTurn, + scheduleReconcile, ] ); @@ -244,6 +286,15 @@ export function useSessionSync( ); useSessionChannel(sessionId, handleChannelEvent); + useExternalHistoryAutoRefresh({ + sessionId, + intervalMs: + ACTIVE_EXTERNAL_SESSION_REFRESH_INTERVAL_MS[ + activeExternalSessionRefreshFrequency + ], + dispatchLoadSession, + }); + useEventStoreCacheSync(sessionId); useSessionSyncCleanup(refs); } diff --git a/src/engines/SessionCore/sync/utils/activityIds.ts b/src/engines/SessionCore/sync/utils/activityIds.ts index 319d0eb74..b61fa9f06 100644 --- a/src/engines/SessionCore/sync/utils/activityIds.ts +++ b/src/engines/SessionCore/sync/utils/activityIds.ts @@ -77,9 +77,12 @@ export function isSyntheticUserInputEvent( export function isBackendUserMessageEvent( event: UserEventIdentityFields ): boolean { + // Backend user turns arrive under two names: live-runner broadcasts + // normalize to functionName "user" (see loadSessionAtom's identity notes), + // while transcript replay/imported loaders emit "user_message". return ( event.source === "user" && - event.functionName === "user_message" && + (event.functionName === "user" || event.functionName === "user_message") && !isSyntheticUserInputEvent(event) ); } diff --git a/src/engines/SessionCore/turns/cursorIdeTurnLoader.ts b/src/engines/SessionCore/turns/cursorIdeTurnLoader.ts index 4babb38e0..2e33de571 100644 --- a/src/engines/SessionCore/turns/cursorIdeTurnLoader.ts +++ b/src/engines/SessionCore/turns/cursorIdeTurnLoader.ts @@ -1,4 +1,4 @@ -import { cursorIdeTurnWindow } from "@src/api/tauri/cursorIde"; +import { cursorIdeTurnWindow } from "@src/api/tauri/externalHistory"; import { eventStoreProxy } from "@src/engines/SessionCore/core/store/EventStoreProxy"; import { processChunksRust } from "@src/engines/SessionCore/ingestion/rustBridge"; import { isCursorIdeSession } from "@src/util/session/sessionDispatch"; diff --git a/src/engines/Simulator/ActivitySimulator.tsx b/src/engines/Simulator/ActivitySimulator.tsx index c8a615490..13e8c8fd9 100644 --- a/src/engines/Simulator/ActivitySimulator.tsx +++ b/src/engines/Simulator/ActivitySimulator.tsx @@ -29,6 +29,7 @@ import { simulatorFollowAppLockAtom, simulatorInlineChatInputCollapsedAtom, simulatorLayoutAtom, + simulatorMiniCPMStepExplanationVisibleAtom, simulatorSelectedAppAtom, simulatorShowDockAtom, } from "@src/store/ui/simulatorAtom"; @@ -42,6 +43,7 @@ import { StationDockChrome, getAppById, } from "./components/Dock"; +import MiniCPMStepExplanationPanel from "./components/MiniCPMStepExplanationPanel"; import MusicPlayerReplayBar from "./components/MusicPlayerReplayBar"; import SimulatorFloatingInput from "./components/SimulatorFloatingInput"; import { SubagentPipCard } from "./components/SubagentPipCard"; @@ -61,6 +63,9 @@ const ActivitySimulator: React.FC = memo(() => { const simulatorInputCollapsed = useAtomValue( simulatorInlineChatInputCollapsedAtom ); + const [showMiniCPMStepExplanation, setShowMiniCPMStepExplanation] = useAtom( + simulatorMiniCPMStepExplanationVisibleAtom + ); const [selectedApp, setSelectedApp] = useAtom(simulatorSelectedAppAtom); const replayMode = useAtomValue(replayModeAtom); const setReplayMode = useSetAtom(replayModeAtom) as ( @@ -83,6 +88,7 @@ const ActivitySimulator: React.FC = memo(() => { previewById, specs, filteredEvents, + allEvents, currentEvent, currentEventIndex, eventStoreVersion, @@ -126,6 +132,7 @@ const ActivitySimulator: React.FC = memo(() => { sessionId, eventStoreVersion, currentEvent, + allEvents, }); // When subagents are active the layout automatically becomes a split view @@ -169,10 +176,9 @@ const ActivitySimulator: React.FC = memo(() => { } // Manually opening the Diff app from the dock is the "whole-session // diff" entry point — clear any per-round scope left over from a chat - // `TurnFilesFooter` "Review" click (which only the composer files-pill + // `TurnMetadataFooter` "Review" click (which only the composer files-pill // otherwise clears) and refresh so the full view reflects the latest - // working tree. Without this the file list stays narrowed to the - // reviewed round while the tab badge shows the full session count. + // working tree without re-applying a stale file-focus request. if ((appId as AppType) === AppType.DIFF) { setDiffScope(null); refreshDiff(); @@ -224,11 +230,15 @@ const ActivitySimulator: React.FC = memo(() => { // grid blank. When it is selected we also have to replace displayEvent with // the raw currentEvent because useSimulatorDisplayState returns null for // displayEvent when selectedApp=BACKGROUND_TASKS (no matching events exist). + // taskThreads is also neutralized: the PIP bottom strip already renders one + // cell per subagent, so letting the TOP pane enter multi-task grid mode + // duplicates the same monitoring row twice (stacked "two rows" look). const isBgTasksSelected = effectiveSelectedApp === AppType.BACKGROUND_TASKS; const splitGridProps = { ...gridProps, forceAppType: isBgTasksSelected ? null : effectiveSelectedApp, currentEvent: isBgTasksSelected ? currentEvent : displayEvent, + taskThreads: [], }; const showFloatingInputOverlay = showDock && !chatVisible && hasSession && !simulatorInputCollapsed; @@ -274,6 +284,14 @@ const ActivitySimulator: React.FC = memo(() => { )} + + {showReplayBar && showMiniCPMStepExplanation && ( +
+ setShowMiniCPMStepExplanation(false)} + /> +
+ )} diff --git a/src/engines/Simulator/ActivitySimulatorGrid.tsx b/src/engines/Simulator/ActivitySimulatorGrid.tsx index a5bb9a3c9..1c0703b72 100644 --- a/src/engines/Simulator/ActivitySimulatorGrid.tsx +++ b/src/engines/Simulator/ActivitySimulatorGrid.tsx @@ -21,7 +21,6 @@ import { useAtomValue } from "jotai"; import React, { memo } from "react"; import { replayModeAtom } from "@src/engines/SessionCore"; -import { globalLayoutMethodAtom } from "@src/store/ui/uiAtom"; import { IndependentGridCell, SimpleGridCell } from "./components/GridCell"; import { MultiTaskHeader } from "./components/MultiTaskHeader"; @@ -65,8 +64,6 @@ const ActivitySimulatorGridComponent: React.FC = ({ taskThreads = [], selectedThreadId = null, }) => { - const globalLayoutMethod = useAtomValue(globalLayoutMethodAtom); - const isFullMode = globalLayoutMethod === "full"; const replayMode = useAtomValue(replayModeAtom); const isFollowingReplay = replayMode === "follow"; @@ -126,13 +123,7 @@ const ActivitySimulatorGridComponent: React.FC = ({ const headerCount = taskThreads.length; return (
{containerContent}
diff --git a/src/engines/Simulator/SimulatorMainPane.tsx b/src/engines/Simulator/SimulatorMainPane.tsx index 373fd2489..f0bfd01ef 100644 --- a/src/engines/Simulator/SimulatorMainPane.tsx +++ b/src/engines/Simulator/SimulatorMainPane.tsx @@ -11,12 +11,10 @@ * - useSimulatorContent: content rendering and caching * - StateDisplays: idle and booting state components */ -import { useAtomValue } from "jotai"; import { type FC, memo } from "react"; import { useBackgroundImage } from "@src/hooks/theme/useBackgroundImage"; import useProgressiveImage from "@src/hooks/ui/effects/useProgressiveImage"; -import { globalLayoutMethodAtom } from "@src/store/ui/uiAtom"; import { SimulatorSingleView } from "./components/SimulatorContentArea/SimulatorSingleView"; import { BootingState } from "./components/SimulatorContentArea/StateDisplays"; @@ -79,12 +77,9 @@ const SimulatorContentAreaComponent: FC = ({ forceAppType, }); - const globalLayoutMethod = useAtomValue(globalLayoutMethodAtom); - const isFullMode = globalLayoutMethod === "full"; - return (
{!isBootingEvent && ( = ({ {isBootingEvent && (
diff --git a/src/engines/Simulator/apps/canvas/CanvasApp.tsx b/src/engines/Simulator/apps/canvas/CanvasApp.tsx index 191ac8a40..a8362f918 100644 --- a/src/engines/Simulator/apps/canvas/CanvasApp.tsx +++ b/src/engines/Simulator/apps/canvas/CanvasApp.tsx @@ -88,6 +88,8 @@ function getDefaultTitle( if (payload.title) return payload.title; if (payload.mode === "url") return t("canvasCard.titleUrl", "Web Page"); if (payload.mode === "a2ui") return t("canvasCard.titleA2ui", "Agent UI"); + if (payload.mode === "react") + return t("canvasCard.titleReact", "React Preview"); return t("canvasCard.titleHtml", "Agent Preview"); } diff --git a/src/engines/Simulator/apps/core/useSimulatorAppState.ts b/src/engines/Simulator/apps/core/useSimulatorAppState.ts index 69472c45e..2fdf7ebc1 100644 --- a/src/engines/Simulator/apps/core/useSimulatorAppState.ts +++ b/src/engines/Simulator/apps/core/useSimulatorAppState.ts @@ -91,24 +91,33 @@ function filterEventIdsForApp( }); } +function isSyntheticLiveEvent(event: SessionEvent): boolean { + return event.args?.syntheticLive === true; +} + function filterLegacyEventsForApp( allEvents: SessionEvent[], currentEventId: string | null, matchesEvent: (eventFunction: string) => boolean, skip: boolean ): SessionEvent[] { - if (!currentEventId) return []; - - const currentIndex = allEvents.findIndex( - (event) => event.id === currentEventId - ); + const currentIndex = currentEventId + ? allEvents.findIndex((event) => event.id === currentEventId) + : -1; const endIndex = currentIndex === -1 ? allEvents.length : currentIndex + 1; const startIndex = Math.max(0, endIndex - MAX_APP_HYDRATION_WINDOW); const eventsUpToCurrent = allEvents.slice(startIndex, endIndex); + const trailingLiveEvents = allEvents + .slice(endIndex) + .filter(isSyntheticLiveEvent); + const visibleEvents = + trailingLiveEvents.length > 0 + ? [...eventsUpToCurrent, ...trailingLiveEvents] + : eventsUpToCurrent; return skip - ? eventsUpToCurrent - : eventsUpToCurrent.filter((event) => matchesEvent(event.functionName)); + ? visibleEvents + : visibleEvents.filter((event) => matchesEvent(event.functionName)); } // ============================================ @@ -153,12 +162,7 @@ export function useSimulatorAppState( const appEventIds = useMemo(() => { if (prefiltered) { - return filterLegacyEventsForApp( - legacyEvents, - currentEventId, - matchesEvent, - true - ).map((event) => event.id); + return []; } return filterEventIdsForApp( @@ -171,7 +175,6 @@ export function useSimulatorAppState( ); }, [ prefiltered, - legacyEvents, currentEventId, appType, matchesEvent, @@ -179,12 +182,26 @@ export function useSimulatorAppState( previewById, ]); + const prefilteredAppEvents = useMemo(() => { + if (!prefiltered) return []; + return filterLegacyEventsForApp( + legacyEvents, + currentEventId, + matchesEvent, + true + ); + }, [prefiltered, legacyEvents, currentEventId, matchesEvent]); + const appEvents = useMemo(() => { + if (prefiltered) { + return hydrateFullEventWindow(prefilteredAppEvents); + } + const hydratedEvents = appEventIds .map((eventId) => eventById.get(eventId)) .filter((event): event is SessionEvent => Boolean(event)); return hydrateFullEventWindow(hydratedEvents); - }, [appEventIds, eventById]); + }, [appEventIds, eventById, prefiltered, prefilteredAppEvents]); // Derive app-specific state const derivedState = useMemo( diff --git a/src/engines/Simulator/components/Dock/Dock.tsx b/src/engines/Simulator/components/Dock/Dock.tsx index f961944a5..c216ccb0f 100644 --- a/src/engines/Simulator/components/Dock/Dock.tsx +++ b/src/engines/Simulator/components/Dock/Dock.tsx @@ -7,7 +7,7 @@ import type { LucideIcon } from "lucide-react"; import React, { memo } from "react"; -import { GENERAL_LAYOUT_TOUR_TARGETS } from "@src/scaffold/Tutorials/GeneralLayoutTour"; +import { GENERAL_LAYOUT_TOUR_TARGETS } from "@src/scaffold/Tutorials/generalLayoutTourConfig"; import { CompactDockIconColumn, diff --git a/src/engines/Simulator/components/Dock/StationDockChrome.tsx b/src/engines/Simulator/components/Dock/StationDockChrome.tsx index 72a6747ec..75994c8c4 100644 --- a/src/engines/Simulator/components/Dock/StationDockChrome.tsx +++ b/src/engines/Simulator/components/Dock/StationDockChrome.tsx @@ -21,7 +21,7 @@ import { SIMULATOR_POINTER_HOVER_CLOSE_DELAY_MS, SIMULATOR_POINTER_HOVER_OPEN_DEBOUNCE_MS, } from "@src/engines/Simulator/constants/simulatorPointerHover"; -import { GENERAL_LAYOUT_TOUR_TARGETS } from "@src/scaffold/Tutorials/GeneralLayoutTour"; +import { GENERAL_LAYOUT_TOUR_TARGETS } from "@src/scaffold/Tutorials/generalLayoutTourConfig"; import { classNames } from "@src/util/ui/classNames"; export interface StationDockChromeProps { diff --git a/src/engines/Simulator/components/Dock/__tests__/dockTitleCenter.test.ts b/src/engines/Simulator/components/Dock/__tests__/dockTitleCenter.test.ts index 83b40f2c1..0708e9cd7 100644 --- a/src/engines/Simulator/components/Dock/__tests__/dockTitleCenter.test.ts +++ b/src/engines/Simulator/components/Dock/__tests__/dockTitleCenter.test.ts @@ -10,7 +10,6 @@ vi.mock("../config", () => ({ vi.mock("lucide-react", () => ({ Code: "CodeIcon", Globe: "GlobeIcon", - Radar: "RadarIcon", ListTodo: "ListTodoIcon", })); @@ -30,12 +29,6 @@ describe("getWorkStationStationTitleCenter", () => { icon: "ListTodoIcon", label: "labels.projectManager", }); - expect(getWorkStationStationTitleCenter("opsControl", navigationT)).toEqual( - { - icon: "RadarIcon", - label: "routes.opsControl", - } - ); }); it("falls back to code editor for unknown modes", () => { diff --git a/src/engines/Simulator/components/Dock/dockTitleCenter.ts b/src/engines/Simulator/components/Dock/dockTitleCenter.ts index f86f9b921..17a87ec76 100644 --- a/src/engines/Simulator/components/Dock/dockTitleCenter.ts +++ b/src/engines/Simulator/components/Dock/dockTitleCenter.ts @@ -4,14 +4,7 @@ */ import type { TFunction } from "i18next"; import type { LucideIcon } from "lucide-react"; -import { - Code, - Globe, - ListTodo, - MonitorDot, - Package2, - Radar, -} from "lucide-react"; +import { Code, Globe, ListTodo, MonitorDot, Package2 } from "lucide-react"; import { APP_TYPE_PROJECT, AppType } from "../../types/appTypes"; import { BACKGROUND_TASKS_DOCK_APP, getAppById } from "./config"; @@ -29,8 +22,6 @@ export function getWorkStationStationTitleCenter( return { icon: Package2, label: t("labels.session") }; case "project": return { icon: ListTodo, label: t("labels.projectManager") }; - case "opsControl": - return { icon: Radar, label: t("routes.opsControl") }; case "other": return { icon: MonitorDot, label: t("labels.other") }; default: diff --git a/src/engines/Simulator/components/FloatingReplayContainer/index.tsx b/src/engines/Simulator/components/FloatingReplayContainer/index.tsx index a8e7ed239..dbda94af6 100644 --- a/src/engines/Simulator/components/FloatingReplayContainer/index.tsx +++ b/src/engines/Simulator/components/FloatingReplayContainer/index.tsx @@ -5,7 +5,7 @@ * The progress slider is now handled by MusicPlayerReplayBar on the dock border. */ import { useAtom, useAtomValue, useSetAtom } from "jotai"; -import { Keyboard } from "lucide-react"; +import { Bot, Keyboard } from "lucide-react"; import React, { memo, useCallback, useEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; @@ -17,10 +17,12 @@ import { simulatorEventCountAtom, } from "@src/engines/SessionCore"; import { getToolDisplayBehavior } from "@src/engines/SessionCore/rendering/registry/initToolRegistry"; +import { useHousekeeperConfig } from "@src/hooks/housekeeper"; import { chatVisibleAtom } from "@src/store/ui/chatPanelAtom"; import { type SimulatorPlaybackSpeed, simulatorInlineChatInputCollapsedAtom, + simulatorMiniCPMStepExplanationVisibleAtom, simulatorPlaybackSpeedAtom, simulatorSessionPlaybackPlayingAtom, } from "@src/store/ui/simulatorAtom"; @@ -51,6 +53,13 @@ const FloatingReplayContainer: React.FC = memo(() => { const currentIndex = useAtomValue(currentSimulatorEventIndexAtom); const eventCount = useAtomValue(simulatorEventCountAtom); const chatVisible = useAtomValue(chatVisibleAtom); + const housekeeper = useHousekeeperConfig(); + const miniCPMStepExplanationVisible = useAtomValue( + simulatorMiniCPMStepExplanationVisibleAtom + ); + const showMiniCPMStepExplanation = useSetAtom( + simulatorMiniCPMStepExplanationVisibleAtom + ); const simulatorInputCollapsed = useAtomValue( simulatorInlineChatInputCollapsedAtom ); @@ -165,6 +174,23 @@ const FloatingReplayContainer: React.FC = memo(() => { playbackSpeed={playbackSpeed} onPlaybackSpeedChange={setPlaybackSpeed} /> + {housekeeper.enabled && + housekeeper.features.stepExplain && + !miniCPMStepExplanationVisible ? ( + {/* Prev / next event — moves the cell's replay cursor by one diff --git a/src/engines/Simulator/components/GridCell/SubagentChatPane.tsx b/src/engines/Simulator/components/GridCell/SubagentChatPane.tsx index ee4f15f6a..dee2cf021 100644 --- a/src/engines/Simulator/components/GridCell/SubagentChatPane.tsx +++ b/src/engines/Simulator/components/GridCell/SubagentChatPane.tsx @@ -46,9 +46,8 @@ * (via `paginationTrailingSlot`) opens a popover showing the original * task prompt — see `SubagentPromptToggle`. A collapse-all button * next to it calls the same global atom the main ChatPanel header - * uses. `PlanTodoPinBar` stays permanently hidden via - * `hidePinnedBars` — it's the parent session's affordance, not the - * subagent cell's. + * uses. Todo progress stays on the parent composer and the cell's scoped + * title-row preview rather than appearing inside this chat history. * * "New event" divider * @@ -203,7 +202,6 @@ const SubagentChatPaneComponent: React.FC = ({ (); +const IDLE_TEXT = "等待 session 步骤产生后,将请求本地 MiniCPM 生成解释。"; +const LOADING_TEXT = "正在请求本地 MiniCPM 解释当前步骤..."; +const DISABLED_TEXT = "常驻管家的步骤解释功能未开启。"; +const UNCONFIGURED_TEXT = + "需要先在常驻管家里配置 MiniCPM vLLM 账号,才能生成步骤解释。"; +const EMPTY_RESPONSE_TEXT = "MiniCPM 返回了空解释,当前步骤暂时没有可用解释。"; + +function rememberExplanation(cacheKey: string, explanation: string): void { + if (STEP_EXPLANATION_CACHE.has(cacheKey)) { + STEP_EXPLANATION_CACHE.delete(cacheKey); + } + STEP_EXPLANATION_CACHE.set(cacheKey, explanation); + if (STEP_EXPLANATION_CACHE.size > STEP_EXPLANATION_CACHE_LIMIT) { + const firstKey = STEP_EXPLANATION_CACHE.keys().next().value; + if (firstKey) STEP_EXPLANATION_CACHE.delete(firstKey); + } +} + +function buildErrorText(error: unknown): string { + const message = + error instanceof Error + ? error.message + : typeof error === "string" + ? error + : ""; + const normalized = message.trim().replace(/\s+/g, " "); + if (!normalized) + return "MiniCPM 暂时无法解释当前步骤,请检查常驻管家连接状态。"; + return `MiniCPM 暂时无法解释当前步骤:${normalized.slice(0, 160)}`; +} + +function buildCacheKey(event: SessionEvent | null): string | null { + if (!event) return null; + return `${event.id}:${event.displayStatus}`; +} + +function toStepExplainRequest(event: SessionEvent) { + return { + eventId: event.id, + functionName: event.functionName, + actionType: event.actionType, + displayText: event.displayText, + displayStatus: event.displayStatus, + displayVariant: event.displayVariant, + source: event.source, + filePath: event.filePath ?? null, + command: event.command ?? null, + args: event.args, + result: event.result, + }; +} + +function useMiniCPMStepExplanation(): ExplanationState & { + currentIndex: number; + eventCount: number; +} { + const event = useAtomValue(currentEventAtom); + const currentIndex = useAtomValue(currentSimulatorEventIndexAtom); + const eventCount = useAtomValue(simulatorEventCountAtom); + const housekeeper = useHousekeeperConfig(); + const cacheKey = useMemo(() => buildCacheKey(event), [event]); + const requestSeqRef = useRef(0); + const [state, dispatchState] = useReducer( + (_prev: ExplanationState, next: ExplanationState) => next, + { + status: "idle", + text: IDLE_TEXT, + cacheKey: cacheKey ?? undefined, + } + ); + + useEffect(() => { + if (!event || !cacheKey) { + dispatchState({ + status: "idle", + text: IDLE_TEXT, + cacheKey: undefined, + }); + return; + } + + if (!housekeeper.enabled || !housekeeper.features.stepExplain) { + dispatchState({ + status: "fallback", + text: DISABLED_TEXT, + cacheKey, + }); + return; + } + + if (!housekeeper.isConfigured) { + dispatchState({ + status: "fallback", + text: UNCONFIGURED_TEXT, + cacheKey, + }); + return; + } + + const cached = STEP_EXPLANATION_CACHE.get(cacheKey); + if (cached) { + dispatchState({ status: "ready", text: cached, cacheKey }); + return; + } + + const requestSeq = ++requestSeqRef.current; + dispatchState({ status: "loading", text: LOADING_TEXT, cacheKey }); + + const timer = window.setTimeout(() => { + void sessionStepExplain(toStepExplainRequest(event), { + accountId: housekeeper.resolvedAccountId ?? undefined, + model: housekeeper.resolvedModel, + }) + .then((response) => { + if (requestSeqRef.current !== requestSeq) return; + const explanation = response.explanation.trim(); + if (!explanation) { + dispatchState({ + status: "fallback", + text: EMPTY_RESPONSE_TEXT, + cacheKey, + }); + return; + } + rememberExplanation(cacheKey, explanation); + dispatchState({ status: "ready", text: explanation, cacheKey }); + }) + .catch((error: unknown) => { + if (requestSeqRef.current !== requestSeq) return; + dispatchState({ + status: "fallback", + text: buildErrorText(error), + cacheKey, + }); + }); + }, 450); + + return () => { + window.clearTimeout(timer); + }; + }, [ + cacheKey, + event, + housekeeper.enabled, + housekeeper.features.stepExplain, + housekeeper.isConfigured, + housekeeper.resolvedAccountId, + housekeeper.resolvedModel, + ]); + + return { ...state, currentIndex, eventCount }; +} + +interface MiniCPMStepExplanationPanelProps { + onClose?: () => void; +} + +const MiniCPMStepExplanationPanel: React.FC = + memo(({ onClose }) => { + const { status, text, currentIndex, eventCount } = + useMiniCPMStepExplanation(); + const hasStep = eventCount > 0 && currentIndex >= 0; + const isUnavailable = status === "fallback"; + const statusLabel = + status === "loading" + ? "MiniCPM 解析中" + : status === "ready" + ? "MiniCPM" + : status === "fallback" + ? "MiniCPM 不可用" + : "等待步骤"; + const icon = + status === "loading" ? ( + + ) : status === "fallback" ? ( + + ) : ( + + ); + + return ( +
+
+ {onClose ? ( + + ) : null} +
+
+ {icon} +
+
+
+ + {statusLabel} + + {hasStep ? ( + + {currentIndex + 1} / {eventCount} + + ) : null} +
+
+ {text} +
+
+
+
+ ); + }); + +MiniCPMStepExplanationPanel.displayName = "MiniCPMStepExplanationPanel"; + +export default MiniCPMStepExplanationPanel; diff --git a/src/engines/Simulator/components/SimulatorContentArea/SimulatorSingleView.tsx b/src/engines/Simulator/components/SimulatorContentArea/SimulatorSingleView.tsx index 034297823..91fe56050 100644 --- a/src/engines/Simulator/components/SimulatorContentArea/SimulatorSingleView.tsx +++ b/src/engines/Simulator/components/SimulatorContentArea/SimulatorSingleView.tsx @@ -12,14 +12,12 @@ * - the floating replay controls * - empty-state placeholder */ -import { useAtomValue } from "jotai"; import React from "react"; import { useTranslation } from "react-i18next"; import { useSessionId } from "@src/engines/SessionCore/hooks/session"; import { AppType } from "@src/engines/Simulator/types/appTypes"; import { NoTabsPlaceholder } from "@src/modules/WorkStation/shared"; -import { globalLayoutMethodAtom } from "@src/store/ui/uiAtom"; import FloatingReplayContainer from "../FloatingReplayContainer"; @@ -39,8 +37,6 @@ export const SimulatorSingleView: React.FC = ({ compactMode = false, }) => { const { t } = useTranslation("sessions"); - const globalLayoutMethod = useAtomValue(globalLayoutMethodAtom); - const isFullMode = globalLayoutMethod === "full"; const { sessionId } = useSessionId(); const hasSession = Boolean(sessionId); @@ -49,7 +45,7 @@ export const SimulatorSingleView: React.FC = ({ const showEmptyTabsPlaceholder = hasSession && !displayContent && !isBootingEvent; - const showRounded = !hideHeader && !isFullMode; + const showRounded = !hideHeader; const showFloatingReplayControls = hasSession && mainContentAppType && mainContentAppType !== AppType.DIFF; diff --git a/src/engines/Simulator/components/SimulatorContentArea/StateDisplays.tsx b/src/engines/Simulator/components/SimulatorContentArea/StateDisplays.tsx index e300f4e4c..54cdbb95d 100644 --- a/src/engines/Simulator/components/SimulatorContentArea/StateDisplays.tsx +++ b/src/engines/Simulator/components/SimulatorContentArea/StateDisplays.tsx @@ -10,7 +10,7 @@ import { memo } from "react"; import { useTranslation } from "react-i18next"; import { SPINNER_TOKENS } from "@src/config/spinnerTokens"; -import { CODEMIRROR_STYLE_NONCE } from "@src/features/CodeMirror/config/csp"; +import { CODEMIRROR_STYLE_NONCE } from "@src/features/CodeMirror/config/nonce"; /** Idle state display - shown when no event is active (Gemini style) */ export const IdleState = memo(() => { diff --git a/src/engines/Simulator/components/SubagentPipCard.tsx b/src/engines/Simulator/components/SubagentPipCard.tsx index 47a60b02d..44a5165c0 100644 --- a/src/engines/Simulator/components/SubagentPipCard.tsx +++ b/src/engines/Simulator/components/SubagentPipCard.tsx @@ -101,18 +101,17 @@ const SubagentPipCard: React.FC = ({ ); const subagentEventsMap = useMultiSessionSimulatorEvents(visibleSessions); - // "Monitoring N" counts only clips still running at the cursor — open - // clips (endedAtMs === null) or clips whose end the cursor hasn't reached. - // Finished in-window clips keep their cell but don't count as monitored. - const runningCount = useMemo( - () => - activeSessions.filter( - (sub) => - sub.endedAtMs === null || - (mainCursorMs != null && mainCursorMs < sub.endedAtMs) - ).length, - [activeSessions, mainCursorMs] - ); + // "Monitoring N" prefers clips still running at the cursor, but completed + // imported/live child clips still count when they are the visible monitor + // rows; otherwise the header would say "Monitoring 0" while showing a cell. + const runningCount = useMemo(() => { + const running = activeSessions.filter( + (sub) => + sub.endedAtMs === null || + (mainCursorMs != null && mainCursorMs < sub.endedAtMs) + ).length; + return running > 0 ? running : activeSessions.length; + }, [activeSessions, mainCursorMs]); // ── Banner collapsed state ──────────────────────────────────────────────── const [isBannerCollapsed, setIsBannerCollapsed] = useState(false); @@ -193,6 +192,28 @@ const SubagentPipCard: React.FC = ({ ); const isAnyExpanded = expandedSessionId !== null || gridExpanded; + // Sticky-state reset: gridExpanded / expandedSessionId survive re-renders + // by design, but when the SET of monitored sessions changes (a batch + // finished, a new turn spawned different workers) a stale 2x2 grid or a + // fullscreen cell from the previous batch is disorienting — the banner + // appears "stuck as two rows". Reset to the plain strip on set change. + const sessionKeySignature = useMemo( + () => + activeSessions + .map((sub) => sub.key) + .sort() + .join(","), + [activeSessions] + ); + const prevSignatureRef = useRef(sessionKeySignature); + useEffect(() => { + if (prevSignatureRef.current === sessionKeySignature) return; + prevSignatureRef.current = sessionKeySignature; + setGridExpanded(false); + setExpandedSessionId(null); + setPageIndex(0); + }, [sessionKeySignature]); + const expandBannerImmediately = useCallback(() => { const bannerPane = bannerPaneRef.current; if (bannerPane) { diff --git a/src/engines/Simulator/hooks/__tests__/useSubagentSessions.test.ts b/src/engines/Simulator/hooks/__tests__/useSubagentSessions.test.ts index 69ea00d8e..abcb6a5fa 100644 --- a/src/engines/Simulator/hooks/__tests__/useSubagentSessions.test.ts +++ b/src/engines/Simulator/hooks/__tests__/useSubagentSessions.test.ts @@ -9,6 +9,7 @@ import { describe, expect, it } from "vitest"; import { type ChildSessionRecord, + SUBAGENT_ACTIVE_LEAD_MS, type SubagentSession, isActiveAtTimestamp, mapChildSessionRecord, @@ -111,8 +112,14 @@ describe("isActiveAtTimestamp", () => { ), }; - it("is inactive before the clip starts", () => { - expect(isActiveAtTimestamp(closedClip, T0 - 1)).toBe(false); + it("is inactive before the lead window starts", () => { + expect( + isActiveAtTimestamp(closedClip, T0 - SUBAGENT_ACTIVE_LEAD_MS - 1) + ).toBe(false); + }); + + it("is active during the pre-start lead window", () => { + expect(isActiveAtTimestamp(closedClip, T0 - 1)).toBe(true); }); it("is active inside the clip window", () => { diff --git a/src/engines/Simulator/hooks/useSimulatorSession.ts b/src/engines/Simulator/hooks/useSimulatorSession.ts index 0e71f9d03..d748bdf7b 100644 --- a/src/engines/Simulator/hooks/useSimulatorSession.ts +++ b/src/engines/Simulator/hooks/useSimulatorSession.ts @@ -15,6 +15,7 @@ import { effectiveSimulatorEventIdsAtom, navigateToFirstSimulatorEventAtom, simulatorEventPreviewByIdAtom, + sortedEventsAtom, sortedSimulatorEventIdsAtom, } from "@src/engines/SessionCore"; import type { @@ -41,6 +42,7 @@ export interface UseSimulatorSessionReturn { previewById: Record; specs: ReturnType["specs"]; filteredEvents: SessionEvent[]; + allEvents: SessionEvent[]; currentEvent: SessionEvent | null; currentEventIndex: number; eventStoreVersion: number; @@ -65,6 +67,7 @@ export function useSimulatorSession(): UseSimulatorSessionReturn { const effectiveEventIds = useAtomValue(effectiveSimulatorEventIdsAtom); const sortedSimulatorEventIds = useAtomValue(sortedSimulatorEventIdsAtom); + const allEvents = useAtomValue(sortedEventsAtom); const previewById = useAtomValue(simulatorEventPreviewByIdAtom); const eventById = useAtomValue(eventIndexAtom); const eventStoreVersion = useAtomValue(eventStoreVersionAtom); @@ -165,6 +168,7 @@ export function useSimulatorSession(): UseSimulatorSessionReturn { previewById, specs, filteredEvents, + allEvents, currentEvent, currentEventIndex, eventStoreVersion, diff --git a/src/engines/Simulator/hooks/useSimulatorSubagents.ts b/src/engines/Simulator/hooks/useSimulatorSubagents.ts index aecf815fb..9f1eaca9a 100644 --- a/src/engines/Simulator/hooks/useSimulatorSubagents.ts +++ b/src/engines/Simulator/hooks/useSimulatorSubagents.ts @@ -16,6 +16,7 @@ import { useAtomValue, useSetAtom } from "jotai"; import { useCallback, useEffect, useMemo, useState } from "react"; import type { SessionEvent } from "@src/engines/SessionCore"; +import { replayModeAtom } from "@src/engines/SessionCore"; import { focusedSubagentCellAtom, simulatorSubagentSessionsAtom, @@ -33,6 +34,65 @@ interface UseSimulatorSubagentsOptions { sessionId: string; eventStoreVersion: number; currentEvent: SessionEvent | null; + allEvents: SessionEvent[]; +} + +function nonEmptyString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 + ? value.trim() + : null; +} + +function subagentIdFromEvent(event: SessionEvent): string | null { + const isSubagentTool = + event.actionType === "tool_call" && + (event.functionName === "subagent" || event.uiCanonical === "subagent"); + if (!isSubagentTool) { + return null; + } + return ( + nonEmptyString(event.args?.subagentSessionId) ?? + nonEmptyString(event.result?.subagentSessionId) + ); +} + +function taskTitleFromEvent(event: SessionEvent, sessionId: string): string { + return ( + nonEmptyString(event.args?.prompt) ?? + nonEmptyString(event.args?.description) ?? + nonEmptyString(event.result?.summary) ?? + nonEmptyString(event.result?.content) ?? + sessionId + ); +} + +function fallbackSubagentSessionsFromEvents( + events: readonly SessionEvent[] +): SubagentSession[] { + const sessions = new Map(); + for (const event of events) { + const sessionId = subagentIdFromEvent(event); + if (!sessionId || sessions.has(sessionId)) continue; + const createdMs = new Date(event.createdAt).getTime(); + const safeCreatedMs = Number.isFinite(createdMs) ? createdMs : Date.now(); + const isCompleted = + event.displayStatus === "completed" || + event.activityStatus === "processed"; + const isFailed = event.displayStatus === "failed"; + sessions.set(sessionId, { + key: sessionId, + sessionId, + name: "OpenCode", + description: taskTitleFromEvent(event, sessionId), + sessionType: "subagent", + status: isFailed ? "failed" : isCompleted ? "completed" : "running", + isBackground: true, + startedAtMs: safeCreatedMs, + endedAtMs: isCompleted || isFailed ? safeCreatedMs : null, + isTerminal: isCompleted || isFailed, + }); + } + return Array.from(sessions.values()); } export interface UseSimulatorSubagentsReturn { @@ -46,20 +106,50 @@ export function useSimulatorSubagents({ sessionId, eventStoreVersion, currentEvent, + allEvents, }: UseSimulatorSubagentsOptions): UseSimulatorSubagentsReturn { const panelRevealRequest = useAtomValue(subagentPanelRevealRequestAtom); const focusedCellId = useAtomValue(focusedSubagentCellAtom); + const setFocusedCellId = useSetAtom(focusedSubagentCellAtom); + const replayMode = useAtomValue(replayModeAtom); const [dismissedSnapshot, setDismissedSnapshot] = useState<{ keys: string; reveal: number; } | null>(null); + // Focus lifecycle: the locate arrow (SubagentAdapter.handleNavigate) seeks + // the main cursor into the subagent's clip window and pins the cell via + // focusedSubagentCellAtom — that pin is only meant for the free-browse + // (replay) session it started. Returning to "Following Agent" means the + // user is done inspecting history, so release the pin; otherwise the + // focused cell is force-prepended forever and never retires with the + // timeline. (No other code path clears this atom.) + useEffect(() => { + if (replayMode === "follow" && focusedCellId !== null) { + setFocusedCellId(null); + } + }, [replayMode, focusedCellId, setFocusedCellId]); + // DB query — re-triggered by eventStoreVersion (bumped on every EventStore // mutation, including args patches like stamp_subagent_session_id_on_parent). - const allSubagentSessions = useSubagentSessions( + const dbSubagentSessions = useSubagentSessions( sessionId || null, eventStoreVersion ); + const eventSubagentSessions = useMemo( + () => fallbackSubagentSessionsFromEvents(allEvents), + [allEvents] + ); + const allSubagentSessions = useMemo(() => { + if (eventSubagentSessions.length === 0) return dbSubagentSessions; + const byId = new Map(dbSubagentSessions.map((sub) => [sub.sessionId, sub])); + for (const fallback of eventSubagentSessions) { + if (!byId.has(fallback.sessionId)) { + byId.set(fallback.sessionId, fallback); + } + } + return Array.from(byId.values()); + }, [dbSubagentSessions, eventSubagentSessions]); // Sync to atom so SessionReplayMessages can read without prop drilling. // Cleanup clears the atom when ActivitySimulator unmounts so stale sessions @@ -74,14 +164,18 @@ export function useSimulatorSubagents({ }; }, [allSubagentSessions, setSimulatorSubagentSessions]); - // Filter to sessions whose time-window covers the current replay cursor. - // When no clip covers the cursor, fall back to clips that are still OPEN - // (endedAtMs === null, i.e. running right now) so a freshly spawned - // subagent is visible even while the main cursor lags behind its - // startedAtMs (the spawning tool_call is filtered from the slider). - // Closed clips deliberately do NOT resurface here — once the cursor - // passes a clip's end, its cell retires from the monitor. The old - // fall-back-to-everything behavior is what made cells accumulate. + // Filter to sessions whose time-window covers the current replay cursor, + // then UNION with clips that are still OPEN (endedAtMs === null, i.e. + // running right now). The union fixes two gaps: + // - a freshly spawned subagent is visible even while the main cursor + // lags behind its startedAtMs (the spawning tool_call is filtered + // from the slider); + // - in live-follow, a running worker stays visible even after a fast + // sibling finished and pulled the parent cursor past its own window. + // Closed clips deliberately do NOT resurface — once the cursor passes a + // clip's end AND it is no longer open, its cell retires from the monitor. + // (The old fall-back-to-everything behavior is what made cells + // accumulate; a union of cursor-active + still-open keeps that fix.) const cursorActiveSubagents = useActiveSubagentsAtCursor( allSubagentSessions, currentEvent @@ -90,6 +184,23 @@ export function useSimulatorSubagents({ () => allSubagentSessions.filter((sub) => sub.endedAtMs === null), [allSubagentSessions] ); + // Imported OpenCode subagent history is already completed by the time it + // shows up in the simulator. Only resurface a completed clip when the replay + // cursor is on that subagent delegate event; once the cursor moves past the + // clip, terminal DB-backed clips must still retire like native SDE subagents. + const currentEventSubagentId = useMemo( + () => (currentEvent ? subagentIdFromEvent(currentEvent) : null), + [currentEvent] + ); + const currentEventCompletedSubagent = useMemo(() => { + if (!currentEventSubagentId) return []; + const sub = allSubagentSessions.find( + (session) => + session.sessionId === currentEventSubagentId && + session.endedAtMs !== null + ); + return sub ? [sub] : []; + }, [allSubagentSessions, currentEventSubagentId]); // A subagent the user explicitly navigated to (clicked the chat block's // locate arrow) must surface even when the replay cursor doesn't land inside // its clip window. The spawning tool_call is filtered out of the simulator @@ -104,8 +215,25 @@ export function useSimulatorSubagents({ : null, [allSubagentSessions, focusedCellId] ); - const baseSubagents = - cursorActiveSubagents.length > 0 ? cursorActiveSubagents : openSubagents; + const baseSubagents = useMemo(() => { + const byId = new Map(); + + for (const sub of cursorActiveSubagents) { + byId.set(sub.sessionId, sub); + } + for (const sub of openSubagents) { + if (!byId.has(sub.sessionId)) { + byId.set(sub.sessionId, sub); + } + } + for (const sub of currentEventCompletedSubagent) { + if (!byId.has(sub.sessionId)) { + byId.set(sub.sessionId, sub); + } + } + + return Array.from(byId.values()); + }, [cursorActiveSubagents, openSubagents, currentEventCompletedSubagent]); const cursorOrAllSubagents = useMemo(() => { if (!focusedSubagent) return baseSubagents; if ( diff --git a/src/engines/Simulator/hooks/useSubagentSessions.ts b/src/engines/Simulator/hooks/useSubagentSessions.ts index ba92040e7..e9773506f 100644 --- a/src/engines/Simulator/hooks/useSubagentSessions.ts +++ b/src/engines/Simulator/hooks/useSubagentSessions.ts @@ -73,6 +73,12 @@ interface ChildSessionRecord { */ const ZOMBIE_ROW_FUSE_MS = 24 * 60 * 60 * 1000; +// The spawning tool_call is filtered out of the main slider, so the last +// visible main-agent event before a subagent starts can be up to ~60 s +// earlier than the subagent's startedAtMs. Treat that short lead window as +// active so even very fast workers can be inspected during replay. +export const SUBAGENT_ACTIVE_LEAD_MS = 90_000; + /** * Coarse display status for sorting/labels only. Clip-window semantics * (open/closed) come exclusively from `isTerminal` / `endedAt`. @@ -164,7 +170,7 @@ export function isActiveAtTimestamp( sub: SubagentSession, cursorMs: number ): boolean { - if (cursorMs < sub.startedAtMs) return false; + if (cursorMs < sub.startedAtMs - SUBAGENT_ACTIVE_LEAD_MS) return false; if (sub.endedAtMs === null) return true; return cursorMs <= sub.endedAtMs; } @@ -229,8 +235,10 @@ export function useSubagentSessions( const lastQueryRef = useRef(null); // Discard stale sessions from a previous parent without an extra render. - const sessions = - rawSessions.parentId === parentSessionId ? rawSessions.list : []; + const sessions = useMemo( + () => (rawSessions.parentId === parentSessionId ? rawSessions.list : []), + [rawSessions, parentSessionId] + ); const setSessions = useCallback( (list: SubagentSession[]) => @@ -256,26 +264,17 @@ export function useSubagentSessions( }) .map((record) => mapChildSessionRecord(record, Date.now())); - // Stable sort: subagents that have actually started (any non-pending - // status) come first so the bottom strip / grid always shows - // populated cells before empty / not-yet-started ones. Within each - // group, preserve insertion order so cells don't reshuffle as the - // backend updates a single status field. - const indexById = new Map( - mapped.map((session, index) => [session.sessionId, index]) - ); - const isReady = (status: SubagentSession["status"]): boolean => - status !== "pending"; + // Sort for the monitor strip: running (open clip) first, then + // finished; within each group NEWEST first, so a freshly spawned + // worker always lands on page 1 of the strip instead of hiding + // behind the pagination. Sort keys (status group + startedAtMs) only + // change on real lifecycle transitions, so cells don't reshuffle as + // the backend updates unrelated fields. mapped.sort((left, right) => { - const leftReady = isReady(left.status); - const rightReady = isReady(right.status); - if (leftReady === rightReady) { - return ( - (indexById.get(left.sessionId) ?? 0) - - (indexById.get(right.sessionId) ?? 0) - ); - } - return leftReady ? -1 : 1; + const leftOpen = left.endedAtMs === null; + const rightOpen = right.endedAtMs === null; + if (leftOpen !== rightOpen) return leftOpen ? -1 : 1; + return right.startedAtMs - left.startedAtMs; }); return mapped; }, diff --git a/src/engines/TerminalCore/index.tsx b/src/engines/TerminalCore/index.tsx index 206db0bd3..108db1035 100644 --- a/src/engines/TerminalCore/index.tsx +++ b/src/engines/TerminalCore/index.tsx @@ -50,6 +50,8 @@ interface SelectionState { visible: boolean; text: string; position: { x: number; y: number }; + lineStart?: number; + lineEnd?: number; } export interface TerminalCoreProps { @@ -63,6 +65,8 @@ export interface TerminalCoreProps { repoPath?: string; /** Opens file references detected in terminal output */ onOpenFileLink?: (target: TerminalFileLinkTarget) => void; + /** True when this terminal tree is visible after tab switching. */ + visible?: boolean; } // ============================================ @@ -75,12 +79,21 @@ export const TerminalCore: React.FC = ({ backgroundColor, repoPath, onOpenFileLink, + visible = true, }) => { const { sessions, activeSessionId, initializedSessions, updateSessionInfo } = terminalState; + const [processRefreshSignal, setProcessRefreshSignal] = useState(0); + + const requestProcessRefresh = useCallback(() => { + if (!visible) return; + setProcessRefreshSignal((signal) => signal + 1); + }, [visible]); useTerminalProcessPoller({ activeSession: terminalState.activeSession, + enabled: visible, + refreshSignal: processRefreshSignal, updateSessionInfo, }); @@ -110,10 +123,21 @@ export const TerminalCore: React.FC = ({ }, [activeSessionId]); useEffect(() => { - if (!activeSessionId) return; + if (!activeSessionId || !visible) return; const handle = terminalRefs.current.get(activeSessionId); handle?.redrawAfterShow(); - }, [activeSessionId]); + const firstFrameId = window.requestAnimationFrame(() => { + handle?.redrawAfterShow(); + }); + const settleTimerId = window.setTimeout(() => { + handle?.redrawAfterShow(); + }, 120); + + return () => { + window.cancelAnimationFrame(firstFrameId); + window.clearTimeout(settleTimerId); + }; + }, [activeSessionId, visible]); useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { @@ -169,6 +193,8 @@ export const TerminalCore: React.FC = ({ sessionId: activeSessionId, sessionName, lineCount, + lineStart: selection.lineStart, + lineEnd: selection.lineEnd, text: selectedText, timestamp: Date.now(), }; @@ -178,7 +204,13 @@ export const TerminalCore: React.FC = ({ return () => { document.removeEventListener("copy", handleTerminalCopy, true); }; - }, [sessions, activeSessionId, selection.text]); + }, [ + sessions, + activeSessionId, + selection.text, + selection.lineStart, + selection.lineEnd, + ]); const handleFindNext = useCallback( ( @@ -218,33 +250,62 @@ export const TerminalCore: React.FC = ({ const handleSelectionChange = useCallback( ( - selectionInfo: { text: string; position: { x: number; y: number } } | null + selectionInfo: { + text: string; + position: { x: number; y: number }; + lineStart?: number; + lineEnd?: number; + } | null ) => { if (selectionInfo && selectionInfo.text.length > 0) { setSelection({ visible: true, text: selectionInfo.text, position: selectionInfo.position, + lineStart: selectionInfo.lineStart, + lineEnd: selectionInfo.lineEnd, }); } else { - setSelection((prev) => ({ ...prev, visible: false })); + setSelection((prev) => ({ + ...prev, + visible: false, + lineStart: undefined, + lineEnd: undefined, + })); } }, [] ); const handleCloseDropdown = useCallback(() => { - setSelection((prev) => ({ ...prev, visible: false })); + setSelection((prev) => ({ + ...prev, + visible: false, + lineStart: undefined, + lineEnd: undefined, + })); }, []); const handleAddToChat = useCallback( (_text: string, _sessionId: string | null) => { if (!selection.text.trim()) return; setStationChatVisible("my-station", true); - setAddToAgent({ type: "terminal", text: selection.text }); + setAddToAgent({ + type: "terminal", + text: selection.text, + lineStart: selection.lineStart, + lineEnd: selection.lineEnd, + }); Message.success(t("terminal.sentToAgent")); }, - [selection.text, setStationChatVisible, setAddToAgent, t] + [ + selection.text, + selection.lineStart, + selection.lineEnd, + setStationChatVisible, + setAddToAgent, + t, + ] ); const bgColor = backgroundColor || "var(--cm-editor-background)"; @@ -299,8 +360,20 @@ export const TerminalCore: React.FC = ({ repoPath={session.cwd || repoPath} workingDirectory={session.liveCwd || session.cwd} onOpenFileLink={onOpenFileLink} + backgroundColor={bgColor} shellOverride={session.shell} + // CLI-agent terminals: pin the PTY to the session's cwd + // (worktree) and let lifecycle hooks attribute status and + // transcripts to the backing managed session row. + forceRepoCwd={Boolean(session.agentCommand)} + envOverride={ + session.agentCommand && session.agentSessionId + ? { ORGII_SESSION_ID: session.agentSessionId } + : undefined + } + nameOverride={session.name} onUserInput={() => { + requestProcessRefresh(); if (!session.hasUserInput) { updateSessionInfo(session.id, { hasUserInput: true }); } @@ -317,15 +390,19 @@ export const TerminalCore: React.FC = ({ shell: info.shell, cwd: info.cwd, }); + requestProcessRefresh(); }} shellIntegration={{ onPromptStart: () => dispatchPromptStart(session.id), - onCommandExecuted: (commandLine) => + onCommandExecuted: (commandLine) => { + requestProcessRefresh(); dispatchCommandExecuted({ sessionId: session.id, commandLine, - }), + }); + }, onCommandFinished: (exitCode) => { + requestProcessRefresh(); dispatchCommandFinished({ sessionId: session.id, exitCode, diff --git a/src/engines/TerminalCore/types.ts b/src/engines/TerminalCore/types.ts index 399512109..7fc76a413 100644 --- a/src/engines/TerminalCore/types.ts +++ b/src/engines/TerminalCore/types.ts @@ -3,8 +3,19 @@ * * Core types for terminal sessions and state management. */ +import type { CliAgentType } from "@src/api/types/keys"; import type { ShellKind } from "@src/types/terminal"; +export const TERMINAL_AGENT_STATUS = { + STARTING: "starting", + RUNNING: "running", + WAITING: "waiting", + DONE: "done", +} as const; + +export type TerminalAgentStatus = + (typeof TERMINAL_AGENT_STATUS)[keyof typeof TERMINAL_AGENT_STATUS]; + export interface TerminalSession { id: string; name: string; @@ -31,6 +42,14 @@ export interface TerminalSession { isDefaultSession?: boolean; /** True after direct user input has been sent to the PTY. */ hasUserInput?: boolean; + /** CLI agent hosted in this terminal, when launched from the chat panel. */ + cliAgentType?: CliAgentType; + /** Command injected to start the CLI agent. */ + agentCommand?: string; + /** Foreground process name expected while the CLI agent is active. */ + expectedProcess?: string; + /** Derived lifecycle state for chat-panel TUI agent tracking. */ + agentStatus?: TerminalAgentStatus; } /** Resolved display title for a terminal session, by priority. */ @@ -44,6 +63,10 @@ export function getTerminalDisplayTitle(session: TerminalSession): string { } export interface AddSessionOptions { + /** Internal setup flows may require a dedicated session immediately after + * the Terminal tab mounts its default session. User-initiated creation must + * leave this false so rapid clicks remain throttled. */ + bypassCreationCooldown?: boolean; /** Shell profile ID to use (if omitted, uses default profile) */ profileId?: string; /** Shell executable path override */ @@ -91,6 +114,7 @@ export interface UseTerminalStateReturn { | "liveCwd" | "isDefaultSession" | "hasUserInput" + | "agentStatus" > > ) => void; diff --git a/src/features/CodeMirror/Diff/index.tsx b/src/features/CodeMirror/Diff/index.tsx index 656118ba4..3732d7fb9 100644 --- a/src/features/CodeMirror/Diff/index.tsx +++ b/src/features/CodeMirror/Diff/index.tsx @@ -31,7 +31,7 @@ import { CustomScrollbar } from "@src/components/CustomScrollbar"; import type { GitFileStatus } from "@src/config/gitStatus"; import { createLogger } from "@src/hooks/logger"; import { useEditorAppearanceSettings } from "@src/hooks/settings"; -import { EditorService } from "@src/services/workStation"; +import { EditorService } from "@src/services/workStation/EditorService"; import { useSelectionExtension } from "../Editor/hooks/useSelectionExtension"; import type { TextSelectionInfo } from "../Editor/types"; @@ -87,6 +87,8 @@ export interface CodeMirrorDiffProps { oldStartLine?: number; /** Starting line number for new/modified content when rendering a diff hunk. */ newStartLine?: number; + /** Show editor gutter line numbers. */ + showLineNumbers?: boolean; /** Custom class name */ className?: string; /** @@ -185,6 +187,7 @@ export const CodeMirrorDiff: React.FC = ({ changeType, oldStartLine = 1, newStartLine = 1, + showLineNumbers = true, className = "", noBottomPadding = false, }) => { @@ -234,12 +237,14 @@ export const CodeMirrorDiff: React.FC = ({ new: string; changeType?: GitFileStatus; startLine: number; + showLineNumbers: boolean; } | null>(null); const splitContentRef = useRef<{ old: string; new: string; oldStartLine: number; newStartLine: number; + showLineNumbers: boolean; } | null>(null); // ── Stable base extensions (rebuilt only when theme/settings change) ───── @@ -253,32 +258,34 @@ export const CodeMirrorDiff: React.FC = ({ exts.push(getCodeMirrorTheme()); exts.push(CODEMIRROR_BASE_LAYOUT_THEME); - if (appearanceSettings.lineNumbers === "on") { - exts.push(lineNumbers({ formatNumber: formatAbsoluteLineNumber })); - } else if (appearanceSettings.lineNumbers === "relative") { - exts.push( - lineNumbers({ - formatNumber: (lineNo: number, state: EditorState) => { - const cursorLine = state.doc.lineAt( - state.selection.main.head - ).number; - return lineNo === cursorLine - ? formatAbsoluteLineNumber(lineNo) - : String(Math.abs(lineNo - cursorLine)); - }, - }) - ); - } else if (appearanceSettings.lineNumbers === "interval") { - exts.push( - lineNumbers({ - formatNumber: (lineNo: number) => { - const absoluteLineNo = lineNo + lineNumberOffset; - return absoluteLineNo === 1 || absoluteLineNo % 10 === 0 - ? String(absoluteLineNo) - : ""; - }, - }) - ); + if (showLineNumbers) { + if (appearanceSettings.lineNumbers === "on") { + exts.push(lineNumbers({ formatNumber: formatAbsoluteLineNumber })); + } else if (appearanceSettings.lineNumbers === "relative") { + exts.push( + lineNumbers({ + formatNumber: (lineNo: number, state: EditorState) => { + const cursorLine = state.doc.lineAt( + state.selection.main.head + ).number; + return lineNo === cursorLine + ? formatAbsoluteLineNumber(lineNo) + : String(Math.abs(lineNo - cursorLine)); + }, + }) + ); + } else if (appearanceSettings.lineNumbers === "interval") { + exts.push( + lineNumbers({ + formatNumber: (lineNo: number) => { + const absoluteLineNo = lineNo + lineNumberOffset; + return absoluteLineNo === 1 || absoluteLineNo % 10 === 0 + ? String(absoluteLineNo) + : ""; + }, + }) + ); + } } if (appearanceSettings.highlightActiveLine) { @@ -325,7 +332,8 @@ export const CodeMirrorDiff: React.FC = ({ if ( prev.old !== oldValue || prev.changeType !== changeType || - prev.startLine !== newStartLine + prev.startLine !== newStartLine || + prev.showLineNumbers !== showLineNumbers ) { unifiedViewRef.current.destroy(); unifiedViewRef.current = null; @@ -349,6 +357,7 @@ export const CodeMirrorDiff: React.FC = ({ new: newValue, changeType, startLine: newStartLine, + showLineNumbers, }; setUnifiedLines(view.state.doc.lines); return; @@ -405,6 +414,7 @@ export const CodeMirrorDiff: React.FC = ({ new: newValue, changeType, startLine: newStartLine, + showLineNumbers, }; setUnifiedScrollEl(view.scrollDOM); setUnifiedLines(view.state.doc.lines); @@ -425,6 +435,7 @@ export const CodeMirrorDiff: React.FC = ({ newValue, changeType, newStartLine, + showLineNumbers, readOnly, mergeControls, collapseUnchanged, @@ -465,7 +476,8 @@ export const CodeMirrorDiff: React.FC = ({ const prev = splitContentRef.current; if ( prev.oldStartLine !== oldStartLine || - prev.newStartLine !== newStartLine + prev.newStartLine !== newStartLine || + prev.showLineNumbers !== showLineNumbers ) { splitMergeViewRef.current.destroy(); splitMergeViewRef.current = null; @@ -495,6 +507,7 @@ export const CodeMirrorDiff: React.FC = ({ new: newValue, oldStartLine, newStartLine, + showLineNumbers, }; setSplitLines(mv.b.state.doc.lines); return; @@ -533,6 +546,7 @@ export const CodeMirrorDiff: React.FC = ({ new: newValue, oldStartLine, newStartLine, + showLineNumbers, }; setSplitScrollEl(splitContainerRef.current); setSplitLines(mergeView.b.state.doc.lines); @@ -563,6 +577,7 @@ export const CodeMirrorDiff: React.FC = ({ newValue, oldStartLine, newStartLine, + showLineNumbers, readOnly, collapseUnchanged, autoHeight, diff --git a/src/features/CodeMirror/Editor/hooks/useEditorServiceRegistration.ts b/src/features/CodeMirror/Editor/hooks/useEditorServiceRegistration.ts index 46d745d4b..e7037b9e0 100644 --- a/src/features/CodeMirror/Editor/hooks/useEditorServiceRegistration.ts +++ b/src/features/CodeMirror/Editor/hooks/useEditorServiceRegistration.ts @@ -7,7 +7,7 @@ import { EditorView } from "@codemirror/view"; import { useCallback, useEffect, useState } from "react"; -import { EditorService } from "@src/services/workStation"; +import { EditorService } from "@src/services/workStation/EditorService"; export interface UseEditorServiceRegistrationOptions { /** Register with EditorService (default: true) */ diff --git a/src/features/CodeMirror/config/csp.ts b/src/features/CodeMirror/config/csp.ts index 56fde0e19..f802e0cbf 100644 --- a/src/features/CodeMirror/config/csp.ts +++ b/src/features/CodeMirror/config/csp.ts @@ -1,6 +1,6 @@ import { EditorView } from "@codemirror/view"; -export const CODEMIRROR_STYLE_NONCE = "orgii-codemirror-style"; +import { CODEMIRROR_STYLE_NONCE } from "./nonce"; export const codeMirrorCspNonceExtension = EditorView.cspNonce.of( CODEMIRROR_STYLE_NONCE diff --git a/src/features/CodeMirror/config/index.ts b/src/features/CodeMirror/config/index.ts index af955aa61..0a3afe005 100644 --- a/src/features/CodeMirror/config/index.ts +++ b/src/features/CodeMirror/config/index.ts @@ -5,7 +5,8 @@ * Consumers can import from "./config" or "../config" as before. */ -export { CODEMIRROR_STYLE_NONCE, codeMirrorCspNonceExtension } from "./csp"; +export { CODEMIRROR_STYLE_NONCE } from "./nonce"; +export { codeMirrorCspNonceExtension } from "./csp"; // Theme configuration export { diff --git a/src/features/CodeMirror/config/nonce.ts b/src/features/CodeMirror/config/nonce.ts new file mode 100644 index 000000000..c68cf9c3f --- /dev/null +++ b/src/features/CodeMirror/config/nonce.ts @@ -0,0 +1 @@ +export const CODEMIRROR_STYLE_NONCE = "orgii-codemirror-style"; diff --git a/src/features/CodeMirror/shared/languageExtensions.ts b/src/features/CodeMirror/shared/languageExtensions.ts index 6c4b0d614..2e4864b4a 100644 --- a/src/features/CodeMirror/shared/languageExtensions.ts +++ b/src/features/CodeMirror/shared/languageExtensions.ts @@ -13,6 +13,9 @@ import { json } from "@codemirror/lang-json"; import { markdown } from "@codemirror/lang-markdown"; import { python } from "@codemirror/lang-python"; import { rust } from "@codemirror/lang-rust"; +import { StreamLanguage } from "@codemirror/language"; +import { dart as dartMode } from "@codemirror/legacy-modes/mode/clike"; +import { go as goMode } from "@codemirror/legacy-modes/mode/go"; import { Extension } from "@codemirror/state"; import { createLogger } from "@src/hooks/logger"; @@ -47,6 +50,7 @@ export const EXT_TO_LANG_MAP: Record = { json: "json", md: "markdown", markdown: "markdown", + dart: "dart", }; // ============================================ @@ -148,6 +152,12 @@ export function getLanguageExtension( case "markdown": ext = markdown(); break; + case "dart": + ext = StreamLanguage.define(dartMode); + break; + case "go": + ext = StreamLanguage.define(goMode); + break; default: return null; } @@ -261,6 +271,17 @@ export async function loadLanguageExtension( ext = mdLang(); break; } + case "dart": { + const { dart: dartMode } = + await import("@codemirror/legacy-modes/mode/clike"); + ext = StreamLanguage.define(dartMode); + break; + } + case "go": { + const { go: goMode } = await import("@codemirror/legacy-modes/mode/go"); + ext = StreamLanguage.define(goMode); + break; + } } } catch (error) { log.warn( diff --git a/src/features/CodeViewer/DiffLineComponent.tsx b/src/features/CodeViewer/DiffLineComponent.tsx index 88f8c0623..6212acf4d 100644 --- a/src/features/CodeViewer/DiffLineComponent.tsx +++ b/src/features/CodeViewer/DiffLineComponent.tsx @@ -13,7 +13,7 @@ import { import React from "react"; import { Prism as PrismHighlighter } from "react-syntax-highlighter"; -import { codeMirrorPrismTheme } from "@src/features/CodeMirror/themes"; +import { codeMirrorPrismTheme } from "@src/features/CodeMirror/themes/prism"; import type { DiffLine } from "./types"; diff --git a/src/features/CodeViewer/ModernCodeViewer.tsx b/src/features/CodeViewer/ModernCodeViewer.tsx index fca1d0559..6049d3008 100644 --- a/src/features/CodeViewer/ModernCodeViewer.tsx +++ b/src/features/CodeViewer/ModernCodeViewer.tsx @@ -15,7 +15,7 @@ import { Prism as PrismHighlighter } from "react-syntax-highlighter"; import { Components, Virtuoso } from "react-virtuoso"; import { getLanguageFromPath } from "@src/config/languageMap"; -import { codeMirrorPrismTheme } from "@src/features/CodeMirror/themes"; +import { codeMirrorPrismTheme } from "@src/features/CodeMirror/themes/prism"; import { getLanguageFromFilePath } from "@src/util/editor/extension"; import "./index.scss"; diff --git a/src/features/CodeViewer/components/SplitRow.tsx b/src/features/CodeViewer/components/SplitRow.tsx index ebd0bbb23..4337e281a 100644 --- a/src/features/CodeViewer/components/SplitRow.tsx +++ b/src/features/CodeViewer/components/SplitRow.tsx @@ -8,7 +8,7 @@ import { Check, Minus, Plus } from "lucide-react"; import React from "react"; import { Prism as PrismHighlighter } from "react-syntax-highlighter"; -import { codeMirrorPrismTheme } from "@src/features/CodeMirror/themes"; +import { codeMirrorPrismTheme } from "@src/features/CodeMirror/themes/prism"; import type { AlignedLine } from "../types"; import { CherryPickCheckbox } from "./CherryPickCheckbox"; diff --git a/src/features/KanbanBoard/components/KanbanColumn/index.scss b/src/features/KanbanBoard/components/KanbanColumn/index.scss index fb3c4ff81..3d365696b 100644 --- a/src/features/KanbanBoard/components/KanbanColumn/index.scss +++ b/src/features/KanbanBoard/components/KanbanColumn/index.scss @@ -7,6 +7,7 @@ flex: 1 0 320px; flex-direction: column; min-width: 320px; + min-height: 0; height: 100%; transition: opacity 0.2s ease; @@ -84,7 +85,7 @@ overflow-y: auto; overflow-x: hidden; transition: background-color 0.2s ease; - min-height: 200px; + min-height: 0; -ms-overflow-style: none; scrollbar-width: none; @@ -93,6 +94,14 @@ } } +.kanban-column__empty { + display: flex; + height: 100%; + min-height: inherit; + align-items: center; + justify-content: center; +} + // ============================================ // Task wrapper - for smooth transitions // ============================================ diff --git a/src/features/KanbanBoard/components/KanbanColumn/index.tsx b/src/features/KanbanBoard/components/KanbanColumn/index.tsx index 64c9a59e1..383e46ae3 100644 --- a/src/features/KanbanBoard/components/KanbanColumn/index.tsx +++ b/src/features/KanbanBoard/components/KanbanColumn/index.tsx @@ -11,6 +11,7 @@ import { verticalListSortingStrategy, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; +import { useVirtualizer } from "@tanstack/react-virtual"; import cn from "classnames"; import { Plus } from "lucide-react"; import React, { @@ -39,6 +40,22 @@ interface DropIndicatorState { beforeTaskId: string | null; } +/** + * Columns at or below this length render every card in normal document flow + * (the pre-virtualization path). This keeps the common case pixel-identical — + * including dnd-kit's live reorder animation — and only pays the virtualization + * cost (and its trade-offs) when a column is long enough to actually matter for + * DOM size / memory. + */ +const STATIC_TASK_RENDER_LIMIT = 40; + +/** + * Seed height for an unmeasured card. Cards are measured on mount (heights vary + * with description, tags, and meta rows), so this only affects the very first + * frame and the scrollbar estimate for not-yet-rendered rows. + */ +const ESTIMATED_TASK_CARD_HEIGHT = 96; + interface ScrollEdgeState { atTop: boolean; atBottom: boolean; @@ -192,7 +209,7 @@ const KanbanColumn: React.FC = ({
{/* `column.title` is the source of truth for the header label. * It must be an i18n key (optionally namespace-prefixed, e.g. - * "sessions:opsControl.boardColumns.todo"). i18next returns the key + * "sessions:kanban.boardColumns.todo"). i18next returns the key * unchanged when no translation is found, so plain strings still * render correctly — but every consumer should pass a key so * locale switching works. */} @@ -217,6 +234,7 @@ const KanbanColumn: React.FC = ({ className={cn("kanban-column__body", { "kanban-column__body--dragging-over": isOver || dropIndicator !== null, + "kanban-column__body--empty": filteredTasks.length === 0, "kanban-column__body--at-top": scrollEdges.atTop, "kanban-column__body--at-bottom": scrollEdges.atBottom, })} @@ -228,27 +246,22 @@ const KanbanColumn: React.FC = ({ disabled={!allowTaskDrag} > {filteredTasks.length === 0 && !showEndIndicator ? ( - +
+ +
) : ( - <> - {filteredTasks.map((task) => ( - - ))} - {/* End of column indicator (when dropping on empty area) */} - {showEndIndicator && } - + )}
@@ -283,6 +296,175 @@ const DropIndicatorLine: React.FC = ({ color }) => { ); }; +// ============================================ +// ColumnTaskList - static / virtualized card list +// ============================================ + +interface ColumnTaskListProps { + tasks: KanbanTask[]; + /** The scrollable column body — used as the virtualizer's scroll element. */ + scrollElementRef: React.MutableRefObject; + onTaskClick?: (task: KanbanTask) => void; + dropIndicator?: DropIndicatorState | null; + columnColor: string; + allowTaskDrag: boolean; + scaleDragTransform: boolean; + useDragOverlay: boolean; + selectedTaskId?: string | null; + showEndIndicator: boolean; +} + +/** + * Renders a column's cards. Short columns take the static flow path (identical + * to the pre-virtualization behaviour, including dnd-kit's reorder animation); + * long columns switch to a windowed renderer so only on-screen cards mount. + * + * The full task-id ordering still lives in the parent ``, so + * drag math stays correct even for cards that aren't currently rendered. + */ +const ColumnTaskList: React.FC = ({ + tasks, + scrollElementRef, + onTaskClick, + dropIndicator, + columnColor, + allowTaskDrag, + scaleDragTransform, + useDragOverlay, + selectedTaskId, + showEndIndicator, +}) => { + const renderCard = useCallback( + (task: KanbanTask, suppressSortTransform: boolean) => ( + + ), + [ + allowTaskDrag, + columnColor, + dropIndicator?.beforeTaskId, + onTaskClick, + scaleDragTransform, + selectedTaskId, + useDragOverlay, + ] + ); + + if (tasks.length <= STATIC_TASK_RENDER_LIMIT) { + return ( + <> + {tasks.map((task) => renderCard(task, false))} + {showEndIndicator && } + + ); + } + + return ( + + ); +}; + +// ============================================ +// VirtualTaskList - windowed card renderer +// ============================================ + +interface VirtualTaskListProps { + tasks: KanbanTask[]; + scrollElementRef: React.MutableRefObject; + renderCard: ( + task: KanbanTask, + suppressSortTransform: boolean + ) => React.ReactNode; + columnColor: string; + showEndIndicator: boolean; +} + +const VirtualTaskList: React.FC = ({ + tasks, + scrollElementRef, + renderCard, + columnColor, + showEndIndicator, +}) => { + // eslint-disable-next-line react-hooks/incompatible-library -- TanStack Virtual exposes imperative helpers that cannot be memoized safely. + const virtualizer = useVirtualizer({ + count: tasks.length, + getScrollElement: () => scrollElementRef.current, + estimateSize: () => ESTIMATED_TASK_CARD_HEIGHT, + overscan: 6, + getItemKey: (index) => tasks[index]?.id ?? index, + }); + const virtualItems = virtualizer.getVirtualItems(); + const totalSize = virtualizer.getTotalSize(); + + // Dynamic measurement — card heights vary (description, tags, meta rows), so + // each rendered row is measured via TanStack's own `measureElement` ref. It + // manages the ResizeObserver internally and unobserves on unmount, so detached + // rows don't leak while scrolling. Because an absolutely-positioned row + // establishes its own block formatting context, the card's `margin-bottom` + // gap is included in the measured height — inter-card spacing is preserved + // with no style changes. + return ( +
+ {virtualItems.map((virtualItem) => { + const task = tasks[virtualItem.index]; + if (!task) return null; + return ( +
+ {/* Sort transform is suppressed in the virtualized path: rows are + positioned by the virtualizer, so a second dnd-kit translate + would fight it. The drop indicator conveys drop position. */} + {renderCard(task, true)} +
+ ); + })} + {showEndIndicator && ( +
+ +
+ )} +
+ ); +}; + // ============================================ // SortableTaskCard - Inner sortable wrapper // ============================================ @@ -296,6 +478,12 @@ interface SortableTaskCardProps { scaleDragTransform?: boolean; useDragOverlay?: boolean; isSelected?: boolean; + /** + * When true, the dnd-kit sort transform/transition are dropped. The + * virtualized column path sets this because it positions each row itself — + * letting the sortable also translate the card would double-offset it. + */ + suppressSortTransform?: boolean; } const SortableTaskCard: React.FC = ({ @@ -307,6 +495,7 @@ const SortableTaskCard: React.FC = ({ scaleDragTransform = true, useDragOverlay = true, isSelected = false, + suppressSortTransform = false, }) => { const { attributes, @@ -319,17 +508,20 @@ const SortableTaskCard: React.FC = ({ // Apply UI scale correction when the consumer opts into it. const uiScale = scaleDragTransform ? getUiScaleFromCssVar() : 1; - const correctedTransform = transform - ? { - ...transform, - x: transform.x / uiScale, - y: transform.y / uiScale, - } - : null; + const correctedTransform = + transform && !suppressSortTransform + ? { + ...transform, + x: transform.x / uiScale, + y: transform.y / uiScale, + } + : null; const style: React.CSSProperties = { transform: CSS.Transform.toString(correctedTransform), - transition: transition || "transform 200ms ease", + transition: suppressSortTransform + ? undefined + : transition || "transform 200ms ease", opacity: useDragOverlay && isDragging ? 0 : 1, zIndex: isDragging ? 999 : "auto", }; diff --git a/src/features/KanbanBoard/components/TaskCard/index.tsx b/src/features/KanbanBoard/components/TaskCard/index.tsx index 082eacffd..5d3445a15 100644 --- a/src/features/KanbanBoard/components/TaskCard/index.tsx +++ b/src/features/KanbanBoard/components/TaskCard/index.tsx @@ -72,7 +72,7 @@ const TaskCard: React.FC = ({ data-testid={`kanban-task-card-${task.id}`} onClick={handleClick} > - {/* Owning Agent Team (only set on the global Ops Control board) */} + {/* Owning Agent Team (only set on the global Kanban board) */} {task.orgName && (
diff --git a/src/features/KanbanBoard/components/TaskImpactLine/index.tsx b/src/features/KanbanBoard/components/TaskImpactLine/index.tsx index 621189a46..b1a94013d 100644 --- a/src/features/KanbanBoard/components/TaskImpactLine/index.tsx +++ b/src/features/KanbanBoard/components/TaskImpactLine/index.tsx @@ -1,6 +1,5 @@ -import { CircleSlash, Diff, GitCommit, LoaderCircle } from "lucide-react"; +import { CircleSlash, Diff, GitCommit } from "lucide-react"; import React from "react"; -import { useTranslation } from "react-i18next"; import DiffStatsBadge from "@src/components/DiffStatsBadge"; @@ -13,14 +12,14 @@ interface TaskImpactLineProps { showUnavailable?: boolean; } -function hasImpactMetadata(task: KanbanTask): boolean { +function hasImpact(task: KanbanTask): boolean { return Boolean( - task.orgtrackMetadata && - (task.orgtrackMetadata.filesChanged > 0 || - task.orgtrackMetadata.linesAdded > 0 || - task.orgtrackMetadata.linesRemoved > 0 || - task.orgtrackMetadata.relatedCommits > 0 || - task.orgtrackMetadata.committedRatePercent > 0) + task.impact && + (task.impact.filesChanged > 0 || + task.impact.linesAdded > 0 || + task.impact.linesRemoved > 0 || + task.impact.relatedCommits > 0 || + task.impact.committedRatePercent > 0) ); } @@ -29,27 +28,18 @@ const TaskImpactLine: React.FC = ({ className, showUnavailable = true, }) => { - const { t } = useTranslation("common"); - const relatedCommits = task.orgtrackMetadata?.relatedCommits ?? 0; + const relatedCommits = task.impact?.relatedCommits ?? 0; const hasRelatedCommits = relatedCommits > 0; const rootClassName = ["task-impact-line", className] .filter(Boolean) .join(" "); - const handleRefreshGitBlame = ( - event: React.MouseEvent - ) => { - event.stopPropagation(); - const refresh = task.onAnalyzeGitBlame ?? task.onUpdateGitBlame; - void refresh?.(task); - }; - - if (hasImpactMetadata(task) && task.orgtrackMetadata) { + if (hasImpact(task) && task.impact) { return ( = ({ - {task.orgtrackMetadata.filesChanged.toLocaleString()} + {task.impact.filesChanged.toLocaleString()} {hasRelatedCommits && ( <> @@ -77,35 +67,8 @@ const TaskImpactLine: React.FC = ({ ); } - if (task.orgtrackMetadataLoading) { - return ( - - - - {t("loading")} - - - ); - } - if (!showUnavailable) return null; - if (task.onAnalyzeGitBlame || task.onUpdateGitBlame) { - return ( - - - - ); - } - return ( diff --git a/src/features/KanbanBoard/config.ts b/src/features/KanbanBoard/config.ts index 034cb61a2..ecde90da2 100644 --- a/src/features/KanbanBoard/config.ts +++ b/src/features/KanbanBoard/config.ts @@ -12,6 +12,11 @@ import { XCircle, } from "lucide-react"; +import { + GITHUB_ISSUE_STATUS, + WORK_ITEM_STATUS, +} from "@src/types/core/workItem"; + import type { KanbanColumnConfig, TaskStatus } from "./types"; // ============================================ @@ -23,7 +28,7 @@ import type { KanbanColumnConfig, TaskStatus } from "./types"; // user's locale. export const DEFAULT_KANBAN_COLUMNS: KanbanColumnConfig[] = [ { - id: "backlog", + id: WORK_ITEM_STATUS.BACKLOG, title: "projects:workItems.statusLabels.backlog", icon: CircleDashed, color: "var(--color-neutral-6)", @@ -32,7 +37,7 @@ export const DEFAULT_KANBAN_COLUMNS: KanbanColumnConfig[] = [ headerBgColor: "color-mix(in srgb, var(--color-neutral-6) 8%, transparent)", }, { - id: "planned", + id: WORK_ITEM_STATUS.PLANNED, title: "projects:workItems.statusLabels.planned", icon: Circle, color: "var(--color-neutral-6)", @@ -41,7 +46,7 @@ export const DEFAULT_KANBAN_COLUMNS: KanbanColumnConfig[] = [ headerBgColor: "color-mix(in srgb, var(--color-neutral-6) 8%, transparent)", }, { - id: "in_progress", + id: WORK_ITEM_STATUS.IN_PROGRESS, title: "projects:workItems.statusLabels.in_progress", icon: Clock, color: "var(--color-primary-6)", @@ -50,7 +55,7 @@ export const DEFAULT_KANBAN_COLUMNS: KanbanColumnConfig[] = [ headerBgColor: "color-mix(in srgb, var(--color-primary-6) 8%, transparent)", }, { - id: "in_review", + id: WORK_ITEM_STATUS.IN_REVIEW, title: "projects:workItems.statusLabels.in_review", icon: Layers, color: "var(--color-warning-6)", @@ -59,7 +64,7 @@ export const DEFAULT_KANBAN_COLUMNS: KanbanColumnConfig[] = [ headerBgColor: "color-mix(in srgb, var(--color-warning-6) 8%, transparent)", }, { - id: "completed", + id: WORK_ITEM_STATUS.COMPLETED, title: "projects:workItems.statusLabels.completed", icon: CheckCircle2, color: "var(--color-success-6)", @@ -68,7 +73,7 @@ export const DEFAULT_KANBAN_COLUMNS: KanbanColumnConfig[] = [ headerBgColor: "color-mix(in srgb, var(--color-success-6) 8%, transparent)", }, { - id: "cancelled", + id: WORK_ITEM_STATUS.CANCELLED, title: "projects:workItems.statusLabels.cancelled", icon: XCircle, color: "var(--color-danger-6)", @@ -77,7 +82,7 @@ export const DEFAULT_KANBAN_COLUMNS: KanbanColumnConfig[] = [ headerBgColor: "color-mix(in srgb, var(--color-danger-6) 8%, transparent)", }, { - id: "duplicate", + id: WORK_ITEM_STATUS.DUPLICATE, title: "projects:workItems.statusLabels.duplicate", icon: XCircle, color: "var(--color-text-3)", @@ -87,6 +92,27 @@ export const DEFAULT_KANBAN_COLUMNS: KanbanColumnConfig[] = [ }, ]; +export const GITHUB_ISSUE_KANBAN_COLUMNS: KanbanColumnConfig[] = [ + { + id: GITHUB_ISSUE_STATUS.OPEN, + title: "projects:workItems.statusLabels.open", + icon: Circle, + color: "var(--color-success-6)", + bgColor: "color-mix(in srgb, var(--color-success-6) 10%, transparent)", + dotColor: "var(--color-success-6)", + headerBgColor: "color-mix(in srgb, var(--color-success-6) 8%, transparent)", + }, + { + id: GITHUB_ISSUE_STATUS.CLOSED, + title: "projects:workItems.statusLabels.closed", + icon: CheckCircle2, + color: "var(--color-text-3)", + bgColor: "color-mix(in srgb, var(--color-text-3) 10%, transparent)", + dotColor: "var(--color-text-3)", + headerBgColor: "color-mix(in srgb, var(--color-text-3) 8%, transparent)", + }, +]; + // ============================================ // Helper Functions // ============================================ diff --git a/src/features/KanbanBoard/index.scss b/src/features/KanbanBoard/index.scss index 784052b28..d40a9a040 100644 --- a/src/features/KanbanBoard/index.scss +++ b/src/features/KanbanBoard/index.scss @@ -126,6 +126,10 @@ &--at-bottom { --kanban-scroll-fade-bottom: 0px; } + + &--empty { + padding-bottom: 0; + } } .kanban-column__count { diff --git a/src/features/KanbanBoard/index.tsx b/src/features/KanbanBoard/index.tsx index 311c4571f..b8f985615 100644 --- a/src/features/KanbanBoard/index.tsx +++ b/src/features/KanbanBoard/index.tsx @@ -431,7 +431,7 @@ const KanbanBoard: React.FC = ({ minHeight: 0, minWidth: 0, overflowX: fitColumnsToContainer ? "hidden" : "auto", - overflowY: "hidden", + overflowY: "auto", }} > {columnOrder.map((column, index) => ( @@ -492,7 +492,7 @@ const KanbanBoard: React.FC = ({
- {t(`sessions:opsControl.boardColumns.${activeColumn.id}`)} + {t(`sessions:kanban.boardColumns.${activeColumn.id}`)}
@@ -514,5 +514,9 @@ export type { TaskPriority, TaskStatus, } from "./types"; -export { DEFAULT_KANBAN_COLUMNS, getColumnConfig } from "./config"; +export { + DEFAULT_KANBAN_COLUMNS, + GITHUB_ISSUE_KANBAN_COLUMNS, + getColumnConfig, +} from "./config"; export { KanbanColumn, TaskCard } from "./components"; diff --git a/src/features/KanbanBoard/types.ts b/src/features/KanbanBoard/types.ts index a302bdf02..9b41f4c10 100644 --- a/src/features/KanbanBoard/types.ts +++ b/src/features/KanbanBoard/types.ts @@ -7,6 +7,7 @@ import type { LucideIcon } from "lucide-react"; import type { CliAgentType } from "@src/api/types/keys"; import type { Label } from "@src/types/core/shared"; +import type { WorkItemStatus } from "@src/types/core/workItem"; // ============================================ // Task Types @@ -14,14 +15,7 @@ import type { Label } from "@src/types/core/shared"; export type TaskPriority = "low" | "medium" | "high" | "urgent"; -export type TaskStatus = - | "backlog" - | "planned" - | "in_progress" - | "in_review" - | "completed" - | "cancelled" - | "duplicate"; +export type TaskStatus = WorkItemStatus | `person:${string}`; export const KANBAN_RESULT_STATUS = { Failed: "failed", @@ -31,7 +25,7 @@ export const KANBAN_RESULT_STATUS = { export type KanbanResultStatus = (typeof KANBAN_RESULT_STATUS)[keyof typeof KANBAN_RESULT_STATUS]; -export interface KanbanTaskOrgtrackMetadata { +export interface SessionImpactStats { filesChanged: number; linesAdded: number; linesRemoved: number; @@ -71,16 +65,14 @@ export interface KanbanTask { cliAgentType?: CliAgentType; /** Raw LLM model id used by the session. */ modelName?: string; - /** Repo-shareable orgtrack metadata for session file/commit attribution. */ - orgtrackMetadata?: KanbanTaskOrgtrackMetadata; - /** True when source impact metadata is known to be unavailable for this task. */ - orgtrackMetadataUnavailable?: boolean; - /** True while explicit Orgtrack / AI Blame analysis is running for this session. */ - orgtrackMetadataLoading?: boolean; - /** Explicitly rebuilds Rust-side Orgtrack / AI Blame analysis for this session. */ - onUpdateGitBlame?: (task: KanbanTask) => void | Promise; - /** Queues Rust-side Orgtrack / AI Blame analysis without rebuilding current artifacts. */ - onAnalyzeGitBlame?: (task: KanbanTask) => void | Promise; + /** Total token usage (input + output) reported by the source, when known. */ + totalTokens?: number; + /** + * Session impact stats — file/line/commit attribution for the card. + * Read-only: parsed from external app data / previously-stored orgtrack + * summaries. The Kanban never computes this on demand. + */ + impact?: SessionImpactStats; /** Display label for the workspace root associated with the session. */ workspaceName?: string; /** diff --git a/src/features/Org2Cloud/CloudAgentRunnerCard.tsx b/src/features/Org2Cloud/CloudAgentRunnerCard.tsx new file mode 100644 index 000000000..fc81d927b --- /dev/null +++ b/src/features/Org2Cloud/CloudAgentRunnerCard.tsx @@ -0,0 +1,250 @@ +/** + * "Agent task runner" Settings card (agent-pickup design §4 UI item 7). + * + * Per-cloud-org defaults for comment-task runs started from a session thread + * ("Run here"): which key-vault account's tokens, which model, and which + * agent exec mode the runner passes to its ONE `sendMessage` drive turn + * (`RunCommentTaskInput.agentOptions`). Persisted in + * `agentTaskRunnerSettingsAtom` (zod localStorage, per-org record); the READ + * side (`resolveAgentRunnerSettings`) defaults mode to 'build'. + * + * The card states the trust boundary explicitly — runs execute on THIS + * machine with the selected account's tokens — because assigning a task in + * the cloud never runs anything anywhere else (design §4: the human "Run + * here" click is the per-run consent). + * + * Pickers: account/model options come from the same key-vault data the chat + * model picker reads (`useModelAccountLookup` / `accountHasModel`), rendered + * with the shared `Select` — the pill/palette selector UIs are + * spotlight-coupled, so v1 reuses the DATA layer, not those components. + * Clearing account/model falls back to the forked session's own defaults. + * + * The auto-run toggle is the owner opt-in for the headless auto-claim plane + * (`kickCommentTaskRunner`). It defaults OFF: a teammate's @agent mention + * never spends the owner's tokens until the owner turns it on here. + */ +import { + SECTION_CONTROL_STYLE, + SECTION_VALUE_SMALL_MUTED_CLASSES, + SectionContainer, + SectionRow, +} from "@/src/modules/shared/layouts/SectionLayout"; +import { useAtom, useAtomValue } from "jotai"; +import React, { useCallback, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import type { SelectOption } from "@src/components/Select"; +import Select from "@src/components/Select"; +import Switch from "@src/components/Switch"; +import { AGENT_EXEC_MODES } from "@src/config/sessionCreatorConfig"; +import { + accountHasModel, + accountModelIds, + useModelAccountLookup, +} from "@src/hooks/models/useModelAccountLookup"; + +import { + agentTaskRunnerSettingsAtom, + resolveAgentRunnerSettings, + withAgentRunnerAutoRun, + withAgentRunnerSetting, +} from "./agentTaskRunnerSettingsAtom"; +import { org2CloudOrgsAtom } from "./org2CloudOrgsAtom"; + +const CloudAgentRunnerCard: React.FC = () => { + const { t } = useTranslation("navigation"); + const cloudOrgs = useAtomValue(org2CloudOrgsAtom); + const [settingsByOrg, setSettingsByOrg] = useAtom( + agentTaskRunnerSettingsAtom + ); + const { accounts } = useModelAccountLookup(); + + // Selected org, self-healing when the org list changes (leave/refetch). + const [pickedOrgId, setPickedOrgId] = useState(null); + const orgId = + pickedOrgId !== null && cloudOrgs.some((org) => org.orgId === pickedOrgId) + ? pickedOrgId + : (cloudOrgs[0]?.orgId ?? null); + + const resolved = useMemo( + () => + orgId !== null ? resolveAgentRunnerSettings(settingsByOrg, orgId) : null, + [settingsByOrg, orgId] + ); + + const orgOptions = useMemo( + () => cloudOrgs.map((org) => ({ value: org.orgId, label: org.name })), + [cloudOrgs] + ); + + /** Enabled key-vault accounts only — a disabled key can never run. */ + const runnableAccounts = useMemo( + () => accounts.filter((account) => account.enabled), + [accounts] + ); + + const accountOptions = useMemo( + () => + runnableAccounts.map((account) => ({ + value: account.id, + label: `${account.name} · ${account.modelType}`, + triggerLabel: account.name, + })), + [runnableAccounts] + ); + + const selectedAccountId = resolved?.accountId; + const selectedAccount = useMemo( + () => + selectedAccountId !== undefined + ? runnableAccounts.find((account) => account.id === selectedAccountId) + : undefined, + [runnableAccounts, selectedAccountId] + ); + + // Selected account ⇒ its enabled models; no account ⇒ the union across + // enabled accounts (same universe the chat model picker offers). + const modelOptions = useMemo(() => { + const modelIds = new Set(); + const sourceAccounts = + selectedAccount !== undefined ? [selectedAccount] : runnableAccounts; + for (const account of sourceAccounts) { + for (const modelId of accountModelIds(account)) { + if (accountHasModel(account, modelId)) modelIds.add(modelId); + } + } + return [...modelIds] + .sort((a, b) => a.localeCompare(b)) + .map((modelId) => ({ value: modelId, label: modelId })); + }, [selectedAccount, runnableAccounts]); + + const modeOptions = useMemo( + () => + AGENT_EXEC_MODES.map((entry) => ({ + value: entry.id, + label: t(`sessions:${entry.i18nKey}`, { defaultValue: entry.name }), + })), + [t] + ); + + const handleAccountChange = useCallback( + (value: string | undefined) => { + if (orgId === null) return; + setSettingsByOrg((previous) => { + let next = withAgentRunnerSetting(previous, orgId, "accountId", value); + // A stored model that the newly-picked account cannot serve is + // cleared in the same write — the drive turn must never pair an + // account with a model it does not have. + const storedModel = next[orgId]?.model; + if (storedModel !== undefined && value !== undefined) { + const nextAccount = runnableAccounts.find( + (account) => account.id === value + ); + if (!nextAccount || !accountHasModel(nextAccount, storedModel)) { + next = withAgentRunnerSetting(next, orgId, "model", undefined); + } + } + return next; + }); + }, + [orgId, runnableAccounts, setSettingsByOrg] + ); + + const handleFieldChange = useCallback( + (field: "model" | "mode", value: string | undefined) => { + if (orgId === null) return; + setSettingsByOrg((previous) => + withAgentRunnerSetting(previous, orgId, field, value) + ); + }, + [orgId, setSettingsByOrg] + ); + + const handleAutoRunChange = useCallback( + (enabled: boolean) => { + if (orgId === null) return; + setSettingsByOrg((previous) => + withAgentRunnerAutoRun(previous, orgId, enabled) + ); + }, + [orgId, setSettingsByOrg] + ); + + // No signed-in cloud org ⇒ nothing to configure (same degradation as the + // orgs atom: [] when signed out or offline). + if (orgId === null || resolved === null) return null; + + return ( + + + + {t("cloud.agentRunner.machineNotice")} + + + {cloudOrgs.length > 1 && ( + + handleAccountChange(undefined)} + onChange={(value) => handleAccountChange(String(value))} + style={SECTION_CONTROL_STYLE} + dataTestId="org2-cloud-agent-runner-account" + /> + + + handleFieldChange("mode", String(value))} + style={SECTION_CONTROL_STYLE} + dataTestId="org2-cloud-agent-runner-mode" + /> + + + ); +}; + +export default CloudAgentRunnerCard; diff --git a/src/features/Org2Cloud/CloudEndpointCard.tsx b/src/features/Org2Cloud/CloudEndpointCard.tsx new file mode 100644 index 000000000..47b8682ce --- /dev/null +++ b/src/features/Org2Cloud/CloudEndpointCard.tsx @@ -0,0 +1,165 @@ +/** + * "Advanced — custom backend" Settings card (cloud-parity Phase C). + * + * A single switch chooses the official managed endpoint or a self-deployed + * backend. Custom endpoint fields are shown only while DIY mode is enabled. + * Both URLs are https-only (zod, `Org2CloudEndpointOverrideSchema`). Applying + * a custom endpoint or switching back to official invalidates every + * backend-coupled piece of state, so both paths run + * `resetCloudStateForEndpointSwitch()` (sign-out included). + * Deliberately no setup help beyond the validation errors: deployment docs + * live in the open-source infra repo. + */ +import { + SECTION_CONTROL_STYLE, + SectionContainer, + SectionRow, +} from "@/src/modules/shared/layouts/SectionLayout"; +import { useAtom, useStore } from "jotai"; +import React, { useCallback, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import Button from "@src/components/Button"; +import Input from "@src/components/Input"; +import Message from "@src/components/Message"; +import Switch from "@src/components/Switch"; + +import { + ORG2_CLOUD_OFFICIAL_ANON_KEY, + ORG2_CLOUD_OFFICIAL_SUPABASE_URL, + ORG2_CLOUD_OFFICIAL_WEB_ORIGIN, + Org2CloudEndpointOverrideSchema, +} from "./config"; +import { + org2CloudEndpointOverrideAtom, + resetCloudStateForEndpointSwitch, +} from "./org2CloudEndpointAtom"; + +interface FieldErrors { + webOrigin?: string; + supabaseUrl?: string; + anonKey?: string; +} + +const CloudEndpointCard: React.FC = () => { + const { t } = useTranslation("navigation"); + const store = useStore(); + const [override, setOverride] = useAtom(org2CloudEndpointOverrideAtom); + const [customEnabled, setCustomEnabled] = useState(override !== null); + const [webOrigin, setWebOrigin] = useState(override?.webOrigin ?? ""); + const [supabaseUrl, setSupabaseUrl] = useState(override?.supabaseUrl ?? ""); + const [anonKey, setAnonKey] = useState(override?.anonKey ?? ""); + const [errors, setErrors] = useState({}); + + const handleApply = useCallback(() => { + const candidate = { + webOrigin: webOrigin.trim(), + supabaseUrl: supabaseUrl.trim(), + anonKey: anonKey.trim(), + }; + const { shape } = Org2CloudEndpointOverrideSchema; + const nextErrors: FieldErrors = {}; + if (!shape.webOrigin.safeParse(candidate.webOrigin).success) { + nextErrors.webOrigin = t("cloud.customEndpoint.httpsRequired"); + } + if (!shape.supabaseUrl.safeParse(candidate.supabaseUrl).success) { + nextErrors.supabaseUrl = t("cloud.customEndpoint.httpsRequired"); + } + if (!shape.anonKey.safeParse(candidate.anonKey).success) { + nextErrors.anonKey = t("cloud.customEndpoint.anonKeyRequired"); + } + setErrors(nextErrors); + if (Object.keys(nextErrors).length > 0) return; + // Typing the official endpoint back in by hand IS a reset-to-official: + // store `null` so future official rotations don't strand a stale copy. + const isOfficialTarget = + candidate.webOrigin === ORG2_CLOUD_OFFICIAL_WEB_ORIGIN && + candidate.supabaseUrl === ORG2_CLOUD_OFFICIAL_SUPABASE_URL && + candidate.anonKey === ORG2_CLOUD_OFFICIAL_ANON_KEY; + const useOfficialEndpoint = isOfficialTarget; + setOverride(useOfficialEndpoint ? null : candidate); + setCustomEnabled(!useOfficialEndpoint); + resetCloudStateForEndpointSwitch(store); + Message.success(t("cloud.customEndpoint.appliedToast")); + }, [anonKey, setOverride, store, supabaseUrl, t, webOrigin]); + + const handleCustomEnabledChange = useCallback( + (enabled: boolean) => { + setCustomEnabled(enabled); + setErrors({}); + if (enabled || override === null) return; + + setOverride(null); + setWebOrigin(""); + setSupabaseUrl(""); + setAnonKey(""); + resetCloudStateForEndpointSwitch(store); + Message.success(t("cloud.customEndpoint.resetToast")); + }, + [override, setOverride, store, t] + ); + + return ( + + + + + {customEnabled && ( + <> + + + + + + + + + + + + + + )} + + ); +}; + +export default CloudEndpointCard; diff --git a/src/features/Org2Cloud/CloudSessionShareDialog/index.tsx b/src/features/Org2Cloud/CloudSessionShareDialog/index.tsx new file mode 100644 index 000000000..84d6edf3c --- /dev/null +++ b/src/features/Org2Cloud/CloudSessionShareDialog/index.tsx @@ -0,0 +1,263 @@ +/** + * CloudSessionShareDialog — owner-side per-session sharing for MANAGED cloud + * orgs (migration 0012). Mounted from the owner's OWN session surfaces + * (sidebar context menu + chat panel header menu), same as the self-hosted + * SessionShareDialog it is modeled on. One section per share-capable cloud + * org: directed member shares and one-shot share links with revocation. + * Access ladder + visibility live in CloudSyncLevelDialog, not here. + */ +import Modal from "@/src/scaffold/ModalSystem"; +import type { TFunction } from "i18next"; +import { Check, Copy } from "lucide-react"; +import React from "react"; +import { useTranslation } from "react-i18next"; + +import Button from "@src/components/Button"; +import Checkbox from "@src/components/Checkbox"; +import type { Session } from "@src/store/session/sessionAtom/types"; +import { formatSmartDateTime } from "@src/util/data/formatters/date"; + +import type { Org2CloudOrg } from "../org2CloudOrgsAtom"; +import { useCloudShareOrgSectionModel } from "./useCloudShareOrgSectionModel"; + +function OrgShareSection({ + t, + session, + org, +}: { + t: TFunction<"navigation">; + session: Session; + org: Org2CloudOrg; +}) { + const model = useCloudShareOrgSectionModel({ session, org }); + + return ( +
+
{org.name}
+ +
+
+ {t("cloud.share.directedTitle")} +
+ {model.grantableMembers.length === 0 ? ( +
+ {t("cloud.share.directedEmpty")} +
+ ) : ( + <> + {model.grantableMembers.length > 1 ? ( +
+ 0 + } + onChange={model.handleToggleSelectAll} + > + {t("cloud.share.selectAll", { + count: model.grantableMembers.length, + })} + +
+ ) : null} +
+ {model.grantableMembers.map((member) => ( +
+ model.handleToggleMember(member.userId)} + > + {member.displayName ?? member.userId} + +
+ ))} +
+
+ +
+ + )} +
+ +
+
+ {t("cloud.share.linkTitle")} +
+
+ +
+ {model.createdLink ? ( +
+ + {model.createdLink.link} + +
+ + {model.createdLinkCopied + ? t("cloud.share.linkCopied") + : t("cloud.share.linkShownOnce")} + + +
+
+ ) : null} +
+ +
+
+ {t("cloud.share.activeShares")} +
+ {model.shares.length === 0 ? ( +
+ {t("cloud.share.noShares")} +
+ ) : ( +
+ {model.shares.map((share) => ( +
+ + {share.granteeUserId + ? (model.memberNameById.get(share.granteeUserId) ?? + share.granteeUserId) + : `${t("cloud.share.linkShareLabel")} #${share.id.slice(-4)}`} + + {formatSmartDateTime(share.createdAt)} + + + +
+ ))} +
+ )} + {model.sharesError ? ( +
+ {t("cloud.share.sharesError")}: {model.sharesError} +
+ ) : null} +
+
+ ); +} + +export interface CloudSessionShareDialogProps { + /** The owner's local session; null keeps the dialog closed. */ + session: Session | null; + /** Share-capable cloud orgs for the session (see useCloudSessionShareDialog). */ + orgs: Org2CloudOrg[]; + onClose: () => void; +} + +const CloudSessionShareDialog: React.FC = ({ + session, + orgs, + onClose, +}) => { + const { t } = useTranslation("navigation"); + + return ( + + {session ? ( +
+
+ {session.name || session.user_input || session.session_id} +
+ {orgs.length === 0 ? ( +
+ {t("cloud.share.noOrgs")} +
+ ) : ( + orgs.map((org) => ( + + )) + )} +
+ ) : null} +
+ ); +}; + +export default CloudSessionShareDialog; diff --git a/src/features/Org2Cloud/CloudSessionShareDialog/shareEligibility.test.ts b/src/features/Org2Cloud/CloudSessionShareDialog/shareEligibility.test.ts new file mode 100644 index 000000000..04ddadd51 --- /dev/null +++ b/src/features/Org2Cloud/CloudSessionShareDialog/shareEligibility.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from "vitest"; + +import type { Org2CloudOrg } from "../org2CloudOrgsAtom"; +import { buildCloudOrgSelectorValue } from "../org2CloudOrgsAtom"; +import { + getActiveCloudShareOrgsForSession, + getCloudShareOrgsForSession, +} from "./shareEligibility"; + +const ORGS: Org2CloudOrg[] = [ + { orgId: "org-a", name: "Alpha", role: "owner" }, + { orgId: "org-b", name: "Beta", role: "member" }, +]; + +describe("getCloudShareOrgsForSession", () => { + it("matches an explicitly cloud-owned session inside the repo scope", () => { + const orgs = getCloudShareOrgsForSession( + { session_id: "s1", orgId: buildCloudOrgSelectorValue("org-a") }, + {}, + ORGS, + { "org-a": ["github.com/acme/alpha"] }, + ["github.com/acme/alpha"] + ); + expect(orgs.map((org) => org.orgId)).toEqual(["org-a"]); + }); + + it("matches a tagged org when ANY checkout remote hits its scope", () => { + const orgs = getCloudShareOrgsForSession( + { session_id: "s1" }, + { s1: [buildCloudOrgSelectorValue("org-b")] }, + ORGS, + { "org-b": ["github.com/team/alpha"] }, + // origin = personal fork (primary), upstream = the team repo. + ["github.com/me/alpha", "github.com/team/alpha"] + ); + expect(orgs.map((org) => org.orgId)).toEqual(["org-b"]); + }); + + it("allows multiple explicitly tagged orgs without duplicating", () => { + const orgs = getCloudShareOrgsForSession( + { session_id: "s1" }, + { + s1: [ + buildCloudOrgSelectorValue("org-a"), + buildCloudOrgSelectorValue("org-b"), + ], + }, + ORGS, + { + "org-a": ["github.com/acme/alpha"], + "org-b": ["github.com/acme/alpha"], + }, + ["github.com/acme/alpha"] + ); + expect(orgs.map((org) => org.orgId)).toEqual(["org-a", "org-b"]); + }); + + it("unresolved (undefined) or absent scope keys match nothing", () => { + expect( + getCloudShareOrgsForSession( + { session_id: "s1", orgId: buildCloudOrgSelectorValue("org-a") }, + {}, + ORGS, + { "org-a": ["github.com/acme/alpha"] }, + undefined + ) + ).toEqual([]); + expect( + getCloudShareOrgsForSession( + { session_id: "s1", orgId: buildCloudOrgSelectorValue("org-a") }, + {}, + ORGS, + { "org-a": ["github.com/acme/alpha"] }, + null + ) + ).toEqual([]); + }); + + it("an org with no scopes is never shareable (scope is the hard boundary)", () => { + const orgs = getCloudShareOrgsForSession( + { session_id: "s1", orgId: buildCloudOrgSelectorValue("org-a") }, + {}, + ORGS, + {}, + ["github.com/acme/alpha"] + ); + expect(orgs).toEqual([]); + }); + + it("never treats a matching remote as org ownership for a Personal session", () => { + const orgs = getCloudShareOrgsForSession( + { session_id: "personal-session", orgId: "personal-org" }, + {}, + ORGS, + { + "org-a": ["github.com/acme/alpha"], + "org-b": ["github.com/acme/alpha"], + }, + ["github.com/acme/alpha"] + ); + expect(orgs).toEqual([]); + }); + + it("does not share a cloud-owned session into a second org with the same remote", () => { + const orgs = getCloudShareOrgsForSession( + { session_id: "s1", orgId: buildCloudOrgSelectorValue("org-a") }, + {}, + ORGS, + { + "org-a": ["github.com/acme/alpha"], + "org-b": ["github.com/acme/alpha"], + }, + ["github.com/acme/alpha"] + ); + expect(orgs.map((org) => org.orgId)).toEqual(["org-a"]); + }); +}); + +describe("getActiveCloudShareOrgsForSession", () => { + const taggedForBoth = { + s1: [ + buildCloudOrgSelectorValue("org-a"), + buildCloudOrgSelectorValue("org-b"), + ], + }; + const sharedScopes = { + "org-a": ["github.com/acme/alpha"], + "org-b": ["github.com/acme/alpha"], + }; + + it("offers no cloud roster while the user is in Personal", () => { + expect( + getActiveCloudShareOrgsForSession( + null, + { session_id: "s1" }, + taggedForBoth, + ORGS, + sharedScopes, + ["github.com/acme/alpha"] + ) + ).toEqual([]); + }); + + it("offers only the currently selected cloud org", () => { + expect( + getActiveCloudShareOrgsForSession( + "org-b", + { session_id: "s1" }, + taggedForBoth, + ORGS, + sharedScopes, + ["github.com/acme/alpha"] + ).map((org) => org.orgId) + ).toEqual(["org-b"]); + }); + + it("does not substitute another org when the active org is ineligible", () => { + expect( + getActiveCloudShareOrgsForSession( + "org-b", + { + session_id: "s1", + orgId: buildCloudOrgSelectorValue("org-a"), + }, + {}, + ORGS, + sharedScopes, + ["github.com/acme/alpha"] + ) + ).toEqual([]); + }); +}); diff --git a/src/features/Org2Cloud/CloudSessionShareDialog/shareEligibility.ts b/src/features/Org2Cloud/CloudSessionShareDialog/shareEligibility.ts new file mode 100644 index 000000000..8b8098f35 --- /dev/null +++ b/src/features/Org2Cloud/CloudSessionShareDialog/shareEligibility.ts @@ -0,0 +1,70 @@ +/** + * Pure eligibility rule for the cloud share dialog (migration 0012): which + * cloud orgs can one of the owner's sessions be shared under? + * + * A repo scope is only the governance boundary; it is NOT ownership. The + * session must also explicitly belong to the org, either because it was + * created under that cloud org (`Session.orgId`) or because the user tagged + * it into the org. This mirrors `Org2CloudSyncEngine.syncAllOrgs` and prevents + * a Personal session from exposing every org that happens to configure the + * same Git remote. + * + * React/Jotai-free by design — unit-testable in isolation. + */ +import { pickMatchingOrgScope } from "@src/features/TeamCollaboration/collabSyncUtils"; +import { + type SessionOrgTags, + cloudOrgIdsForSession, +} from "@src/features/TeamCollaboration/sessionOrgTagsAtom"; +import type { Session } from "@src/store/session/sessionAtom/types"; + +import { + type Org2CloudOrg, + parseCloudOrgSelectorValue, +} from "../org2CloudOrgsAtom"; + +export function getCloudShareOrgsForSession( + session: Pick, + sessionOrgTags: SessionOrgTags, + cloudOrgs: Org2CloudOrg[], + repoScopesByOrg: Record, + /** Resolved remote scope keys; null = no remote, undefined = resolving. */ + scopeKeys: string[] | null | undefined +): Org2CloudOrg[] { + const explicitOrgIds = new Set( + cloudOrgIdsForSession(sessionOrgTags, session.session_id) + ); + const owningCloudOrgId = session.orgId + ? parseCloudOrgSelectorValue(session.orgId) + : null; + if (owningCloudOrgId) explicitOrgIds.add(owningCloudOrgId); + + return cloudOrgs.filter( + (org) => + explicitOrgIds.has(org.orgId) && + pickMatchingOrgScope(scopeKeys, repoScopesByOrg[org.orgId]) !== null + ); +} + +/** + * User-facing share sections are scoped to the org selected in the sidebar. + * Personal means no cloud share affordance, even when a session retains an + * explicit cloud tag; selecting a cloud org exposes only that org's roster. + */ +export function getActiveCloudShareOrgsForSession( + activeCloudOrgId: string | null, + session: Pick, + sessionOrgTags: SessionOrgTags, + cloudOrgs: Org2CloudOrg[], + repoScopesByOrg: Record, + scopeKeys: string[] | null | undefined +): Org2CloudOrg[] { + if (!activeCloudOrgId) return []; + return getCloudShareOrgsForSession( + session, + sessionOrgTags, + cloudOrgs, + repoScopesByOrg, + scopeKeys + ).filter((org) => org.orgId === activeCloudOrgId); +} diff --git a/src/features/Org2Cloud/CloudSessionShareDialog/sharePreparation.test.ts b/src/features/Org2Cloud/CloudSessionShareDialog/sharePreparation.test.ts new file mode 100644 index 000000000..f26474cfd --- /dev/null +++ b/src/features/Org2Cloud/CloudSessionShareDialog/sharePreparation.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from "vitest"; + +import type { RemoteTeammateSessionMetadata } from "@src/store/collaboration/types"; + +import { + applyCloudReplaySharePolicy, + assertCloudReplayPublished, + restoreCloudReplaySharePolicy, +} from "./sharePreparation"; + +describe("cloud replay-share preparation", () => { + it("promotes only the target session and restores its exact prior override", () => { + const initial = { + "org-1": { + defaultMode: "off" as const, + sessionModes: { + other: "metadata_only" as const, + target: "off" as const, + }, + sessionVisibility: { target: "restricted" as const }, + }, + }; + + const { next, snapshot } = applyCloudReplaySharePolicy( + initial, + "org-1", + "target" + ); + expect(next["org-1"].sessionModes).toEqual({ + other: "metadata_only", + target: "full_replay", + }); + expect(next["org-1"].sessionVisibility).toEqual({ + target: "restricted", + }); + + const concurrentlyChanged = { + ...next, + "org-1": { + ...next["org-1"], + sessionModes: { + ...next["org-1"].sessionModes, + concurrent: "full_replay" as const, + }, + }, + }; + expect( + restoreCloudReplaySharePolicy( + concurrentlyChanged, + "org-1", + "target", + snapshot + )["org-1"].sessionModes + ).toEqual({ + other: "metadata_only", + target: "off", + concurrent: "full_replay", + }); + }); + + it("restores org-default inheritance instead of inventing an override", () => { + const initial = { + "org-1": { + defaultMode: "metadata_only" as const, + sessionModes: {}, + sessionVisibility: {}, + }, + }; + const { next, snapshot } = applyCloudReplaySharePolicy( + initial, + "org-1", + "target" + ); + expect(next["org-1"].sessionModes.target).toBe("full_replay"); + expect( + restoreCloudReplaySharePolicy(next, "org-1", "target", snapshot)["org-1"] + .sessionModes + ).toEqual({}); + }); + + it("does not clobber a newer target-session policy choice", () => { + const initial = { + "org-1": { + defaultMode: "off" as const, + sessionModes: { target: "metadata_only" as const }, + sessionVisibility: {}, + }, + }; + const { next, snapshot } = applyCloudReplaySharePolicy( + initial, + "org-1", + "target" + ); + const changedWhilePublishing = { + ...next, + "org-1": { + ...next["org-1"], + sessionModes: { + ...next["org-1"].sessionModes, + target: "off" as const, + }, + }, + }; + + expect( + restoreCloudReplaySharePolicy( + changedWhilePublishing, + "org-1", + "target", + snapshot + ) + ).toBe(changedWhilePublishing); + }); + + it("accepts only a server-confirmed full replay row", () => { + const row = { + sourceSessionId: "target", + ownerUserId: "owner-1", + accessMode: "full_replay", + eventsEpoch: 1, + eventsCount: 0, + } as RemoteTeammateSessionMetadata; + expect(() => + assertCloudReplayPublished([row], "target", "owner-1") + ).not.toThrow(); + expect(() => + assertCloudReplayPublished([row], "target", "other-owner") + ).toThrow(/not confirmed/); + expect(() => + assertCloudReplayPublished( + [{ ...row, accessMode: "metadata_only" }], + "target", + "owner-1" + ) + ).toThrow(/not confirmed/); + expect(() => + assertCloudReplayPublished( + [{ ...row, eventsCount: undefined }], + "target", + "owner-1" + ) + ).toThrow(/not confirmed/); + expect(() => assertCloudReplayPublished([], "target", "owner-1")).toThrow( + /not confirmed/ + ); + }); +}); diff --git a/src/features/Org2Cloud/CloudSessionShareDialog/sharePreparation.ts b/src/features/Org2Cloud/CloudSessionShareDialog/sharePreparation.ts new file mode 100644 index 000000000..c9e7113b0 --- /dev/null +++ b/src/features/Org2Cloud/CloudSessionShareDialog/sharePreparation.ts @@ -0,0 +1,92 @@ +/** + * Policy transition used by both directed-member and link sharing. + * + * A replay share is useful only when the owner has actually published a full + * transcript. Keep that invariant in one place: the share UI temporarily + * installs an explicit `full_replay` override, drains the sync engine, then + * verifies server truth before it creates any grant. If publication fails, + * the caller restores the exact previous override with the snapshot below. + * + * Visibility is deliberately preserved. A directed grant may be useful for + * notification/filtering even when the session is already org-visible, and a + * share action must not silently hide the row from other org members. + */ +import type { RemoteTeammateSessionMetadata } from "@src/store/collaboration/types"; +import { COLLAB_SESSION_ACCESS_MODE } from "@src/store/collaboration/types"; + +import type { CloudAccessSettingsByOrg } from "../org2CloudAccessSettings"; +import { withCloudSessionMode } from "../org2CloudAccessSettings"; + +export interface CloudReplaySharePolicySnapshot { + /** null means the session previously followed the org default. */ + modeOverride: "off" | "metadata_only" | "full_replay" | null; +} + +export function applyCloudReplaySharePolicy( + byOrg: CloudAccessSettingsByOrg, + orgId: string, + sessionId: string +): { + next: CloudAccessSettingsByOrg; + snapshot: CloudReplaySharePolicySnapshot; +} { + const snapshot: CloudReplaySharePolicySnapshot = { + modeOverride: byOrg[orgId]?.sessionModes[sessionId] ?? null, + }; + return { + next: withCloudSessionMode( + byOrg, + orgId, + sessionId, + COLLAB_SESSION_ACCESS_MODE.FULL_REPLAY + ), + snapshot, + }; +} + +export function restoreCloudReplaySharePolicy( + byOrg: CloudAccessSettingsByOrg, + orgId: string, + sessionId: string, + snapshot: CloudReplaySharePolicySnapshot +): CloudAccessSettingsByOrg { + // Compare-and-restore: do not overwrite a newer explicit choice made while + // publication/grant RPCs were in flight. Only the full-replay value this + // transaction installed is ours to roll back. + if ( + byOrg[orgId]?.sessionModes[sessionId] !== + COLLAB_SESSION_ACCESS_MODE.FULL_REPLAY + ) { + return byOrg; + } + return withCloudSessionMode(byOrg, orgId, sessionId, snapshot.modeOverride); +} + +/** + * Server-readback assertion. The sync engine intentionally logs ordinary + * background push failures instead of throwing; explicit sharing must be + * stricter, so it verifies the exact row and the replay summary before any + * grant is minted. + */ +export function assertCloudReplayPublished( + rows: readonly RemoteTeammateSessionMetadata[], + sessionId: string, + ownerUserId: string +): void { + // Session ids originate on clients and can legitimately collide (for + // example two ORGII instances on one Mac both exposing the same Codex + // history). Never let a teammate's same-id row prove OUR publication. + const row = rows.find( + (candidate) => + candidate.sourceSessionId === sessionId && + candidate.ownerUserId === ownerUserId + ); + if ( + !row || + row.accessMode !== COLLAB_SESSION_ACCESS_MODE.FULL_REPLAY || + row.eventsEpoch === undefined || + row.eventsCount === undefined + ) { + throw new Error("Full replay publication was not confirmed by the server"); + } +} diff --git a/src/features/Org2Cloud/CloudSessionShareDialog/useCloudSessionShareDialog.ts b/src/features/Org2Cloud/CloudSessionShareDialog/useCloudSessionShareDialog.ts new file mode 100644 index 000000000..099fee1fd --- /dev/null +++ b/src/features/Org2Cloud/CloudSessionShareDialog/useCloudSessionShareDialog.ts @@ -0,0 +1,127 @@ +/** + * Per-surface open/close state + eligibility gate for the cloud + * SessionShareDialog, mirroring `useCloudSyncLevelDialog` / + * `useSessionShareDialog`: signed into ORG2 Cloud, the session is the + * owner's own pushable session, and it belongs to ≥1 cloud org (repo-scope + * matched via any of the checkout's remotes — see + * `getCloudShareOrgsForSession`). + */ +import { useAtomValue, useStore } from "jotai"; +import { useCallback, useMemo, useState, useSyncExternalStore } from "react"; + +import { + getShareableScopeKeyVersion, + peekShareableScopeKeys, + primeShareableScopeKey, + subscribeShareableScopeKeys, +} from "@src/features/TeamCollaboration/repoScopeResolver"; +import { sessionOrgTagsAtom } from "@src/features/TeamCollaboration/sessionOrgTagsAtom"; +import type { Session } from "@src/store/session/sessionAtom/types"; + +import { org2CloudAuthAtom } from "../org2CloudAuthAtom"; +import { + type Org2CloudOrg, + org2CloudOrgsAtom, + sidebarActiveCloudOrgIdAtom, +} from "../org2CloudOrgsAtom"; +import { org2CloudRepoScopesAtom } from "../org2CloudSyncAtoms"; +import { isCloudPushCandidate } from "../org2CloudSyncEngine"; +import { getActiveCloudShareOrgsForSession } from "./shareEligibility"; + +/** + * Resolved scope keys for one session, backed by the module-level resolver + * cache the sync engine also feeds (same idiom as useSessionShareDialog: + * `undefined` = still resolving → not eligible yet; the subscription + * re-renders the consumer when the keys land). + */ +function getSessionScopeKeys(session: Session): string[] | null | undefined { + if (!session.repoPath) return null; + const keys = peekShareableScopeKeys(session.repoPath); + if (keys === undefined) primeShareableScopeKey(session.repoPath); + return keys; +} + +export interface UseCloudSessionShareDialogResult { + /** Session the dialog is open for; null = closed. */ + cloudShareSession: Session | null; + /** Share-capable cloud orgs for the open session (dialog sections). */ + cloudShareOrgs: Org2CloudOrg[]; + isCloudShareEligible: (session: Session) => boolean; + openCloudShare: (session: Session) => void; + closeCloudShare: () => void; +} + +export function useCloudSessionShareDialog(): UseCloudSessionShareDialogResult { + const store = useStore(); + const cloudOrgs = useAtomValue(org2CloudOrgsAtom); + const repoScopesByOrg = useAtomValue(org2CloudRepoScopesAtom); + const sessionOrgTags = useAtomValue(sessionOrgTagsAtom); + const activeCloudOrgId = useAtomValue(sidebarActiveCloudOrgIdAtom); + const [cloudShareSession, setCloudShareSession] = useState( + null + ); + // Re-render when any repo scope key resolves (async git-remote IPC). + const scopeKeyVersion = useSyncExternalStore( + subscribeShareableScopeKeys, + getShareableScopeKeyVersion + ); + + const orgsForSession = useCallback( + (session: Session) => + getActiveCloudShareOrgsForSession( + activeCloudOrgId, + session, + sessionOrgTags, + cloudOrgs, + repoScopesByOrg, + getSessionScopeKeys(session) + ), + // scopeKeyVersion is the cache's change signal, not an input value. + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + activeCloudOrgId, + cloudOrgs, + repoScopesByOrg, + scopeKeyVersion, + sessionOrgTags, + ] + ); + + // Store reads at call time — the gate runs inside a native context-menu + // handler and must see the live roster/scopes, never a render-time capture. + const isCloudShareEligible = useCallback( + (session: Session) => + store.get(org2CloudAuthAtom) !== null && + isCloudPushCandidate(session) && + getActiveCloudShareOrgsForSession( + store.get(sidebarActiveCloudOrgIdAtom), + session, + store.get(sessionOrgTagsAtom), + store.get(org2CloudOrgsAtom), + store.get(org2CloudRepoScopesAtom), + getSessionScopeKeys(session) + ).length > 0, + [store] + ); + + const cloudShareOrgs = useMemo( + () => (cloudShareSession ? orgsForSession(cloudShareSession) : []), + [cloudShareSession, orgsForSession] + ); + + const openCloudShare = useCallback((session: Session) => { + setCloudShareSession(session); + }, []); + + const closeCloudShare = useCallback(() => { + setCloudShareSession(null); + }, []); + + return { + cloudShareSession, + cloudShareOrgs, + isCloudShareEligible, + openCloudShare, + closeCloudShare, + }; +} diff --git a/src/features/Org2Cloud/CloudSessionShareDialog/useCloudShareOrgSectionModel.test.ts b/src/features/Org2Cloud/CloudSessionShareDialog/useCloudShareOrgSectionModel.test.ts new file mode 100644 index 000000000..18827c978 --- /dev/null +++ b/src/features/Org2Cloud/CloudSessionShareDialog/useCloudShareOrgSectionModel.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; + +import { + type CreatedCloudShareLink, + reconcileCreatedLinkAfterRevoke, +} from "./useCloudShareOrgSectionModel"; + +const CREATED_LINK: CreatedCloudShareLink = { + shareId: "share-link-1", + link: "orgii://cloud/session?share=token", + copied: true, +}; + +describe("reconcileCreatedLinkAfterRevoke", () => { + it("clears the one-shot plaintext after its grant is revoked", () => { + expect( + reconcileCreatedLinkAfterRevoke(CREATED_LINK, "share-link-1") + ).toBeNull(); + }); + + it("preserves it when a different directed or link share is revoked", () => { + expect( + reconcileCreatedLinkAfterRevoke(CREATED_LINK, "share-directed-2") + ).toBe(CREATED_LINK); + }); + + it("keeps an empty state empty", () => { + expect(reconcileCreatedLinkAfterRevoke(null, "share-link-1")).toBeNull(); + }); +}); diff --git a/src/features/Org2Cloud/CloudSessionShareDialog/useCloudShareOrgSectionModel.ts b/src/features/Org2Cloud/CloudSessionShareDialog/useCloudShareOrgSectionModel.ts new file mode 100644 index 000000000..1acef7d32 --- /dev/null +++ b/src/features/Org2Cloud/CloudSessionShareDialog/useCloudShareOrgSectionModel.ts @@ -0,0 +1,411 @@ +/** + * One org section of the cloud session share dialog: directed member shares + * + one-shot link shares with revocation, backed by the 0012 share RPCs. + * Modeled on the self-hosted `useSessionShareOrgSectionModel`, minus the + * override/visibility pills (the cloud access ladder lives in + * CloudSyncLevelDialog). + * + * Shares are granted at 'replay' level — a deliberate share means "watch + * this session", and the server's resolve path only honors replay link + * shares anyway. + */ +import { useAtom, useStore } from "jotai"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import type { Session } from "@src/store/session/sessionAtom/types"; +import { copyText } from "@src/util/data/clipboard"; + +import { org2CloudAccessSettingsAtom } from "../org2CloudAccessSettings"; +import { commitRefreshedAuth, org2CloudAuthAtom } from "../org2CloudAuthAtom"; +import { + type CloudOrgMember, + ensureFreshSession, + listOrgMembers, +} from "../org2CloudClient"; +import { buildCloudSessionShareLink } from "../org2CloudOrgManagement"; +import type { Org2CloudOrg } from "../org2CloudOrgsAtom"; +import { + CLOUD_SHARE_LEVEL, + type CloudSessionShareRecord, + createCloudSessionShare, + isCloudShareActive, + listCloudSessionShares, + revokeCloudSessionShare, +} from "../org2CloudSharesClient"; +import { listOrgSessions } from "../org2CloudSyncClient"; +import { org2CloudSyncEngine } from "../org2CloudSyncEngine"; +import { + type CloudReplaySharePolicySnapshot, + applyCloudReplaySharePolicy, + assertCloudReplayPublished, + restoreCloudReplaySharePolicy, +} from "./sharePreparation"; + +export interface CreatedCloudShareLink { + shareId: string; + link: string; + copied: boolean; +} + +/** + * A one-shot plaintext link is useful only while its matching server grant is + * active. Revoking another share must leave it alone; revoking this grant must + * remove it immediately so the dialog cannot offer a known-dead credential. + */ +export function reconcileCreatedLinkAfterRevoke( + created: CreatedCloudShareLink | null, + revokedShareId: string +): CreatedCloudShareLink | null { + return created?.shareId === revokedShareId ? null : created; +} + +export function useCloudShareOrgSectionModel({ + session, + org, +}: { + session: Session; + org: Org2CloudOrg; +}) { + const store = useStore(); + const [auth, setAuth] = useAtom(org2CloudAuthAtom); + const [shares, setShares] = useState([]); + const [members, setMembers] = useState([]); + const [sharesError, setSharesError] = useState(null); + const [busy, setBusy] = useState(false); + const [selectedMemberIds, setSelectedMemberIds] = useState([]); + /** Plaintext link of the share created in THIS dialog session — shown once. */ + const [createdLink, setCreatedLink] = useState( + null + ); + + // Latest auth via ref so token-refresh writes don't recreate callbacks + // (useCloudSessionActions idiom). + const authRef = useRef(auth); + useEffect(() => { + authRef.current = auth; + }, [auth]); + const selfUserId = auth?.userId ?? null; + + const freshAccessToken = useCallback(async (): Promise => { + const current = authRef.current; + if (!current) return null; + const fresh = await ensureFreshSession(current); + if (!fresh) return null; + commitRefreshedAuth(setAuth, current, fresh); + return fresh.accessToken; + }, [setAuth]); + + const refreshShares = useCallback(async () => { + const accessToken = await freshAccessToken(); + if (!accessToken) return; + try { + const rows = await listCloudSessionShares( + accessToken, + org.orgId, + session.session_id + ); + setShares(rows.filter((row) => isCloudShareActive(row))); + setSharesError(null); + } catch (error) { + // Most common cause: the session row does not exist server-side yet + // (never pushed). The dialog stays usable — share actions surface the + // same error on demand. + setShares([]); + setSharesError(error instanceof Error ? error.message : String(error)); + } + }, [freshAccessToken, org.orgId, session.session_id]); + + useEffect(() => { + void refreshShares(); + }, [refreshShares]); + + // Roster for the directed multi-select. `[]` on failure (listOrgMembers + // swallows errors by design) — the picker just renders empty. + useEffect(() => { + let cancelled = false; + void (async () => { + const accessToken = await freshAccessToken(); + if (!accessToken) return; + const roster = await listOrgMembers(accessToken, org.orgId); + if (!cancelled) setMembers(roster); + })(); + return () => { + cancelled = true; + }; + }, [freshAccessToken, org.orgId]); + + const activeGranteeIds = useMemo( + () => + new Set( + shares + .map((share) => share.granteeUserId) + .filter((id): id is string => Boolean(id)) + ), + [shares] + ); + + // Active members minus self and minus members already holding a grant. + const grantableMembers = useMemo( + () => + members.filter( + (member) => + member.status === "active" && + member.userId !== selfUserId && + !activeGranteeIds.has(member.userId) + ), + [activeGranteeIds, members, selfUserId] + ); + + const memberNameById = useMemo(() => { + const map = new Map(); + for (const member of members) { + map.set(member.userId, member.displayName ?? member.userId); + } + return map; + }, [members]); + + const handleToggleMember = useCallback((userId: string) => { + setSelectedMemberIds((current) => + current.includes(userId) + ? current.filter((id) => id !== userId) + : [...current, userId] + ); + }, []); + + const grantableIds = useMemo( + () => grantableMembers.map((member) => member.userId), + [grantableMembers] + ); + + // "Everyone" is checked only when every currently-grantable member is + // selected (empty roster ⇒ not checked). Toggling flips the whole roster: + // select-all replaces the selection with the exact grantable set (so stale + // ids from a roster change can't linger), deselect-all clears it. + const allGrantableSelected = + grantableIds.length > 0 && + grantableIds.every((id) => selectedMemberIds.includes(id)); + + const handleToggleSelectAll = useCallback(() => { + setSelectedMemberIds((current) => { + const everySelected = + grantableIds.length > 0 && + grantableIds.every((id) => current.includes(id)); + return everySelected ? [] : [...grantableIds]; + }); + }, [grantableIds]); + + /** + * Make the replay grant truthful before creating it. Background sync is + * intentionally best-effort/log-only, so an explicit share drains it and + * then reads the server row back. The returned token is fresh enough for + * the immediately-following grant RPC. + */ + const prepareReplayShare = useCallback(async () => { + const current = store.get(org2CloudAccessSettingsAtom); + const { next, snapshot } = applyCloudReplaySharePolicy( + current, + org.orgId, + session.session_id + ); + store.set(org2CloudAccessSettingsAtom, next); + try { + org2CloudSyncEngine.resumeOrg(org.orgId); + await org2CloudSyncEngine.runSyncPassAndWaitForDrain(); + const accessToken = await freshAccessToken(); + if (!accessToken) throw new Error("Not signed in"); + const ownerUserId = authRef.current?.userId; + if (!ownerUserId) throw new Error("Not signed in"); + const published = await listOrgSessions(accessToken, org.orgId); + assertCloudReplayPublished( + published.sessions, + session.session_id, + ownerUserId + ); + return { accessToken, snapshot }; + } catch (error) { + store.set(org2CloudAccessSettingsAtom, (latest) => + restoreCloudReplaySharePolicy( + latest, + org.orgId, + session.session_id, + snapshot + ) + ); + org2CloudSyncEngine.resumeOrg(org.orgId); + await org2CloudSyncEngine.runSyncPassAndWaitForDrain().catch(() => {}); + throw error; + } + }, [freshAccessToken, org.orgId, session.session_id, store]); + + const rollbackReplayShare = useCallback( + async (snapshot: CloudReplaySharePolicySnapshot) => { + store.set(org2CloudAccessSettingsAtom, (latest) => + restoreCloudReplaySharePolicy( + latest, + org.orgId, + session.session_id, + snapshot + ) + ); + org2CloudSyncEngine.resumeOrg(org.orgId); + await org2CloudSyncEngine.runSyncPassAndWaitForDrain(); + }, + [org.orgId, session.session_id, store] + ); + + const handleCreateDirectedShares = useCallback(async () => { + if (selectedMemberIds.length === 0) return; + setBusy(true); + try { + const selected = [...selectedMemberIds]; + const { accessToken, snapshot } = await prepareReplayShare(); + // One grant per member. A mid-batch failure must not lose the grants + // that already succeeded, nor re-issue them on retry — keep only the + // members that still failed selected and reconcile against the server. + const failed: string[] = []; + let firstError: string | null = null; + for (const granteeUserId of selected) { + try { + await createCloudSessionShare(accessToken, { + orgId: org.orgId, + sessionId: session.session_id, + level: CLOUD_SHARE_LEVEL.REPLAY, + granteeUserId, + }); + } catch (error) { + failed.push(granteeUserId); + if (!firstError) { + firstError = error instanceof Error ? error.message : String(error); + } + } + } + // No grant survived: restore the exact access override from before the + // button click. A partial success keeps full replay because those + // recipients already depend on it. + if (failed.length === selected.length) { + await rollbackReplayShare(snapshot); + } + setSelectedMemberIds(failed); + await refreshShares(); + setSharesError(firstError); + } catch (error) { + setSharesError(error instanceof Error ? error.message : String(error)); + } finally { + setBusy(false); + } + }, [ + org.orgId, + prepareReplayShare, + refreshShares, + rollbackReplayShare, + selectedMemberIds, + session.session_id, + ]); + + const handleCreateLinkShare = useCallback(async () => { + setBusy(true); + let prepared: Awaited> | null = null; + let grantCreated = false; + try { + prepared = await prepareReplayShare(); + const { shareId, shareToken } = await createCloudSessionShare( + prepared.accessToken, + { + orgId: org.orgId, + sessionId: session.session_id, + level: CLOUD_SHARE_LEVEL.REPLAY, + } + ); + if (!shareToken) { + await revokeCloudSessionShare( + prepared.accessToken, + org.orgId, + shareId + ).catch(() => {}); + throw new Error("Share token missing"); + } + grantCreated = true; + const link = buildCloudSessionShareLink(shareToken); + // The plaintext exists only here. Keep it visible until the dialog + // closes and let the user copy explicitly; clipboard permissions can + // reject a background/implicit write, and a visible retry action is + // essential because the server can never return the token again. + setCreatedLink({ shareId, link, copied: false }); + setSharesError(null); + await refreshShares(); + } catch (error) { + if (prepared && !grantCreated) { + await rollbackReplayShare(prepared.snapshot).catch(() => {}); + } + setSharesError(error instanceof Error ? error.message : String(error)); + } finally { + setBusy(false); + } + }, [ + org.orgId, + prepareReplayShare, + refreshShares, + rollbackReplayShare, + session.session_id, + ]); + + const handleCopyCreatedLink = useCallback(async () => { + if (!createdLink) return; + try { + // WKWebView may reject the browser clipboard API after the async + // create-share round trip. The shared helper falls back to the native + // Tauri command and then a DOM copy, keeping this one-time plaintext + // usable without weakening its explicit-copy UX. + await copyText(createdLink.link); + setCreatedLink((current) => + current ? { ...current, copied: true } : current + ); + setSharesError(null); + } catch (error) { + setCreatedLink((current) => + current ? { ...current, copied: false } : current + ); + setSharesError(error instanceof Error ? error.message : String(error)); + } + }, [createdLink]); + + const handleRevokeShare = useCallback( + async (shareId: string) => { + setBusy(true); + try { + const accessToken = await freshAccessToken(); + if (!accessToken) throw new Error("Not signed in"); + await revokeCloudSessionShare(accessToken, org.orgId, shareId); + setCreatedLink((current) => + reconcileCreatedLinkAfterRevoke(current, shareId) + ); + setSharesError(null); + await refreshShares(); + } catch (error) { + setSharesError(error instanceof Error ? error.message : String(error)); + } finally { + setBusy(false); + } + }, + [freshAccessToken, org.orgId, refreshShares] + ); + + return { + shares, + sharesError, + busy, + grantableMembers, + memberNameById, + selectedMemberIds, + createdLink, + createdLinkCopied: createdLink?.copied ?? false, + canShare: auth !== null, + allGrantableSelected, + handleToggleSelectAll, + handleToggleMember, + handleCreateDirectedShares, + handleCreateLinkShare, + handleCopyCreatedLink, + handleRevokeShare, + }; +} diff --git a/src/features/Org2Cloud/CloudShareImportDialog.tsx b/src/features/Org2Cloud/CloudShareImportDialog.tsx new file mode 100644 index 000000000..4c02d7c16 --- /dev/null +++ b/src/features/Org2Cloud/CloudShareImportDialog.tsx @@ -0,0 +1,260 @@ +/** + * CloudShareImportDialog — consumer side of the cloud share deep link + * (migration 0012): confirmation dialog → resolveCloudSessionShare(token) → + * read-only import through the shared segments importer → openSession. + * + * Guests (not signed in, not a member) are first-class: the token IS the + * credential — resolve and every segments fetch ride the anon TICKET tier, + * the imported copy lands as an `external_history` session with no `orgId` + * (sidebar Personal area), and no org records are created. Signed-in + * members go through the exact same path; the token authenticates the read + * regardless. + * + * The pending atom itself is the dialog state: it stays set while the + * confirmation is open and is consumed (cleared) exactly once on close, so a + * re-render can never replay the hand-off. All per-link results are keyed by + * the share token, so a newer link invalidates stale resolve/import state. + * Modeled on CollabShareImportDialog (minus the combined-invite CTA — cloud + * share links carry only the token). + */ +import Modal from "@/src/scaffold/ModalSystem"; +import { useAtomValue, useSetAtom } from "jotai"; +import React, { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react"; +import { useTranslation } from "react-i18next"; + +import Button from "@src/components/Button"; +import { importRemoteSession } from "@src/features/TeamCollaboration/engine/collabSyncEngineHelpers"; +import { resolveForkWorkspacePath } from "@src/features/TeamCollaboration/forkSession"; +import { useSessionView } from "@src/hooks/ui/tabs/useSessionView"; +import { openOrReplaceSessionInChatPanelTabAtom } from "@src/store/chatPanel/chatPanelTabsAtom"; +import type { RemoteTeammateSessionMetadata } from "@src/store/collaboration/types"; + +import { buildCloudSessionFetchClient } from "./org2CloudBackendAdapter"; +import { + consumeOrg2CloudPendingShareAtom, + org2CloudPendingShareAtom, +} from "./org2CloudPendingShareAtom"; +import { resolveCloudSessionShare } from "./org2CloudSharesClient"; + +interface ResolveState { + token: string; + session: RemoteTeammateSessionMetadata | null; + failed: boolean; +} + +interface ImportState { + token: string; + status: "importing" | "failed"; +} + +const CloudShareImportDialog: React.FC = () => { + const { t } = useTranslation("navigation"); + const { openSession } = useSessionView(); + const openOrReplaceSessionTab = useSetAtom( + openOrReplaceSessionInChatPanelTabAtom + ); + const share = useAtomValue(org2CloudPendingShareAtom); + const consumePendingShare = useSetAtom(consumeOrg2CloudPendingShareAtom); + + const [resolveState, setResolveState] = useState(null); + const [importState, setImportState] = useState(null); + const activeTokenRef = useRef(null); + const importGenerationRef = useRef(0); + const shareToken = share?.shareToken ?? null; + + // Commit the current hand-off before a user can interact with the painted + // dialog. A layout effect keeps ref access outside render while still + // invalidating an older import before the browser paints the new token. + useLayoutEffect(() => { + activeTokenRef.current = shareToken; + importGenerationRef.current += 1; + }, [shareToken]); + + // Resolve the token to the session projection (title/owner shown in the + // confirmation). State updates only happen in the async callback, keyed by + // token. + useEffect(() => { + if (!shareToken) return; + let cancelled = false; + resolveCloudSessionShare(shareToken) + .then((session) => { + if (!cancelled) { + setResolveState({ token: shareToken, session, failed: false }); + } + }) + .catch(() => { + if (!cancelled) { + // Terminal resolution cancels any in-flight visual state. Without + // this, the dialog can show an invalid/revoked error beside a stale + // spinning Import button. + importGenerationRef.current += 1; + setImportState(null); + setResolveState({ token: shareToken, session: null, failed: true }); + } + }); + return () => { + cancelled = true; + }; + }, [shareToken]); + + const resolved = + share && resolveState?.token === share.shareToken ? resolveState : null; + const currentImport = + share && importState?.token === share.shareToken ? importState : null; + + // Resolve failure = the token itself is invalid/expired/revoked/aged-out + // (the server's answer is deliberately opaque). Import failure is + // DIFFERENT and retryable: a transient network error, or a valid share + // whose owner hasn't pushed event segments yet. + const resolveFailed = Boolean(resolved?.failed); + const isImporting = currentImport?.status === "importing"; + const importFailed = currentImport?.status === "failed" && !resolveFailed; + const canImport = + Boolean(resolved?.session) && !resolveFailed && !isImporting; + + const handleClose = useCallback(() => { + activeTokenRef.current = null; + importGenerationRef.current += 1; + setImportState(null); + // One-shot consume: clears the atom so nothing can replay this link. + consumePendingShare(); + }, [consumePendingShare]); + + const handleImport = useCallback(async () => { + if (!share || !resolved?.session || resolveFailed || isImporting) return; + const token = share.shareToken; + const generation = ++importGenerationRef.current; + setImportState({ token, status: "importing" }); + try { + const localRepoPath = + (await resolveForkWorkspacePath(resolved.session)) ?? undefined; + const result = await importRemoteSession({ + // TICKET tier: anon fetch client — the share token authenticates + // every segments read, member or not. + client: buildCloudSessionFetchClient(null), + orgId: resolved.session.orgId, + remoteSession: resolved.session, + shareToken: token, + workspaceRepoPath: localRepoPath, + }); + if ( + activeTokenRef.current !== token || + importGenerationRef.current !== generation + ) { + return; + } + if (!result) { + setImportState({ token, status: "failed" }); + return; + } + openOrReplaceSessionTab({ + sessionId: result.localSessionId, + sessionName: resolved.session.title, + repoPath: localRepoPath, + }); + openSession(result.localSessionId, resolved.session.title, localRepoPath); + handleClose(); + } catch { + if ( + activeTokenRef.current === token && + importGenerationRef.current === generation + ) { + setImportState({ token, status: "failed" }); + } + } + }, [ + handleClose, + isImporting, + openOrReplaceSessionTab, + openSession, + resolveFailed, + resolved, + share, + ]); + + return ( + +
+ {!resolved && !resolveFailed ? ( +
+ {t("cloud.share.incomingResolving")} +
+ ) : null} + + {resolveFailed ? ( +
+ {t("cloud.share.incomingError")} +
+ ) : null} + + {resolved?.session && !resolveFailed ? ( +
+
+ {resolved.session.title} +
+
+ {t("cloud.share.incomingOwner")}:{" "} + {resolved.session.ownerDisplayName} +
+ {resolved.session.repoPath ? ( +
+ {resolved.session.repoPath} +
+ ) : null} +
+ ) : null} + + {importFailed ? ( +
+ {t("cloud.share.incomingRetryHint")} +
+ ) : null} + +
+ + {!resolveFailed ? ( + + ) : null} +
+
+
+ ); +}; + +export default CloudShareImportDialog; diff --git a/src/features/Org2Cloud/CloudSyncLevelDialog/index.tsx b/src/features/Org2Cloud/CloudSyncLevelDialog/index.tsx new file mode 100644 index 000000000..8f246f262 --- /dev/null +++ b/src/features/Org2Cloud/CloudSyncLevelDialog/index.tsx @@ -0,0 +1,276 @@ +/** + * CloudSyncLevelDialog — per-session cloud access ladder editor (§13.4). + * + * Opened from the session context menu ("Cloud sync level…"). One row per + * currently selected cloud org: a sync-level Select (Org default / Off / + * Metadata only / Full replay) and a visibility Select (Everyone in org / + * Only me). Personal/local scope exposes no cross-org sharing controls. + * Writes land in the persisted `org2CloudAccessSettingsAtom` — the ratchet + * store the push engine re-reads every pass — and kick a sync pass so + * upgrades publish promptly. Downgrades stop FUTURE pushes; rows already on + * the server keep their last pushed level until untagged/deleted (the 0010 + * server enforces reads by the persisted columns either way). + */ +import Modal from "@/src/scaffold/ModalSystem"; +import { useAtom, useAtomValue } from "jotai"; +import React, { useCallback, useEffect, useMemo } from "react"; +import { useTranslation } from "react-i18next"; + +import Select from "@src/components/Select"; +import { + isSessionTaggedToCloudOrg, + sessionOrgTagsAtom, +} from "@src/features/TeamCollaboration/sessionOrgTagsAtom"; +import { + COLLAB_SESSION_ACCESS_MODE, + COLLAB_SESSION_VISIBILITY, +} from "@src/store/collaboration/types"; +import type { + CollabSessionAccessMode, + CollabSessionVisibility, +} from "@src/store/collaboration/types"; +import type { Session } from "@src/store/session/sessionAtom/types"; + +import { + floorAccessMode, + getCloudOrgAccessSettings, + getOrgSharingFloor, + isAccessModeAtLeast, + org2CloudAccessSettingsAtom, + org2CloudSharingFloorAtom, + withCloudSessionMode, + withCloudSessionVisibility, +} from "../org2CloudAccessSettings"; +import { + getSidebarActiveCloudOrg, + org2CloudOrgsAtom, + sidebarActiveCloudOrgIdAtom, +} from "../org2CloudOrgsAtom"; +import { org2CloudSyncEngine } from "../org2CloudSyncEngine"; + +/** Sentinel select value for "no per-session override" (org default). */ +const USE_ORG_DEFAULT = "__org_default__"; +/** Ladder order for building the (floor-filtered) per-session mode options. */ +const ACCESS_MODE_LADDER = [ + COLLAB_SESSION_ACCESS_MODE.OFF, + COLLAB_SESSION_ACCESS_MODE.METADATA_ONLY, + COLLAB_SESSION_ACCESS_MODE.FULL_REPLAY, +] as const; +/** Above the modal wrapper (9999) so the panel is not swallowed by the mask. */ +const MODAL_SELECT_Z_INDEX = 10_000; + +export interface CloudSyncLevelDialogProps { + /** The owner's local session; null keeps the dialog closed. */ + session: Session | null; + onClose: () => void; +} + +const CloudSyncLevelDialog: React.FC = ({ + session, + onClose, +}) => { + const { t } = useTranslation("navigation"); + const cloudOrgs = useAtomValue(org2CloudOrgsAtom); + const activeCloudOrgId = useAtomValue(sidebarActiveCloudOrgIdAtom); + const [accessByOrg, setAccessByOrg] = useAtom(org2CloudAccessSettingsAtom); + const floorByOrg = useAtomValue(org2CloudSharingFloorAtom); + const tags = useAtomValue(sessionOrgTagsAtom); + const activeCloudOrg = useMemo( + () => getSidebarActiveCloudOrg(activeCloudOrgId, cloudOrgs), + [activeCloudOrgId, cloudOrgs] + ); + + // A scope switch while the dialog is open must not leave a hidden stale + // editor that can reappear later under a different organization. + useEffect(() => { + if (session && !activeCloudOrg) onClose(); + }, [activeCloudOrg, onClose, session]); + + const modeLabels = useMemo( + () => ({ + [COLLAB_SESSION_ACCESS_MODE.OFF]: t("cloud.syncLevel.modeOff"), + [COLLAB_SESSION_ACCESS_MODE.METADATA_ONLY]: t( + "cloud.syncLevel.modeMetadata" + ), + [COLLAB_SESSION_ACCESS_MODE.FULL_REPLAY]: t( + "cloud.syncLevel.modeFullReplay" + ), + }), + [t] + ); + + const handleModeChange = useCallback( + (orgId: string, value: string | number | (string | number)[]) => { + if (!session) return; + setAccessByOrg((current) => + withCloudSessionMode( + current, + orgId, + session.session_id, + value === USE_ORG_DEFAULT ? null : (value as CollabSessionAccessMode) + ) + ); + org2CloudSyncEngine.resumeOrg(orgId); + void org2CloudSyncEngine.runSyncPassAndWaitForDrain(); + }, + [session, setAccessByOrg] + ); + + const handleVisibilityChange = useCallback( + (orgId: string, value: string | number | (string | number)[]) => { + if (!session) return; + setAccessByOrg((current) => + withCloudSessionVisibility( + current, + orgId, + session.session_id, + value as CollabSessionVisibility + ) + ); + org2CloudSyncEngine.resumeOrg(orgId); + void org2CloudSyncEngine.runSyncPassAndWaitForDrain(); + }, + [session, setAccessByOrg] + ); + + const visibilityOptions = useMemo( + () => [ + { + value: COLLAB_SESSION_VISIBILITY.ORG, + label: t("cloud.syncLevel.visibilityOrg"), + dataTestId: "session-sync-level-visibility-option-org", + }, + { + value: COLLAB_SESSION_VISIBILITY.RESTRICTED, + label: t("cloud.syncLevel.visibilityRestricted"), + dataTestId: "session-sync-level-visibility-option-restricted", + }, + ], + [t] + ); + + return ( + + {session ? ( +
+
+ {session.name || session.user_input || session.session_id} +
+
+ {t("cloud.syncLevel.hint")} +
+ {!activeCloudOrg ? ( +
+ {t("cloud.moveToOrg.noOrgs")} +
+ ) : ( +
+ {[activeCloudOrg].map((org) => { + const settings = getCloudOrgAccessSettings( + accessByOrg, + org.orgId + ); + const overrideMode = + settings.sessionModes[session.session_id] ?? null; + const visibility = + settings.sessionVisibility[session.session_id] ?? + COLLAB_SESSION_VISIBILITY.ORG; + const tagged = isSessionTaggedToCloudOrg( + tags, + session.session_id, + org.orgId + ); + // Admin sharing FLOOR (0002): drop every mode below the floor + // from the picker so a member can't author a sub-floor value + // (the engine + server floor it anyway). The org-default + // sentinel's label reflects the FLOORED default. + const floor = getOrgSharingFloor(floorByOrg, org.orgId); + const modeOptions = [ + { + value: USE_ORG_DEFAULT, + label: t("cloud.syncLevel.orgDefaultOption", { + mode: modeLabels[ + floorAccessMode(settings.defaultMode, floor) + ], + }), + dataTestId: `session-sync-level-mode-option-${org.orgId}-default`, + }, + ...ACCESS_MODE_LADDER.filter((mode) => + isAccessModeAtLeast(mode, floor) + ).map((mode) => ({ + value: mode, + label: modeLabels[mode], + dataTestId: `session-sync-level-mode-option-${org.orgId}-${mode}`, + })), + ]; + // A stale sub-floor override shows as its floored value (what + // actually gets pushed), never a now-hidden option. + const selectedMode = overrideMode + ? floorAccessMode(overrideMode, floor) + : USE_ORG_DEFAULT; + return ( +
+ {org.name} +
+ + handleVisibilityChange(org.orgId, value) + } + size="small" + className="min-w-0 flex-1" + panelZIndex={MODAL_SELECT_Z_INDEX} + dataTestId={`session-sync-level-visibility-${org.orgId}`} + /> +
+ {floor !== COLLAB_SESSION_ACCESS_MODE.OFF ? ( + // Admin policy: this org mandates a minimum sharing level. + + {t("cloud.syncLevel.floorNote", { + mode: modeLabels[floor], + })} + + ) : null} + {tagged ? ( + // Tagged ("moved") sessions never drop below metadata: + // the engine floors effective-off to metadata_only so + // the explicit move isn't silently a no-op. + + {t("cloud.syncLevel.taggedNote")} + + ) : null} +
+ ); + })} +
+ )} +
+ ) : null} +
+ ); +}; + +export default CloudSyncLevelDialog; diff --git a/src/features/Org2Cloud/CloudSyncLevelDialog/useCloudSyncLevelDialog.ts b/src/features/Org2Cloud/CloudSyncLevelDialog/useCloudSyncLevelDialog.ts new file mode 100644 index 000000000..cccff8d53 --- /dev/null +++ b/src/features/Org2Cloud/CloudSyncLevelDialog/useCloudSyncLevelDialog.ts @@ -0,0 +1,79 @@ +/** + * Per-surface open/close state + eligibility gate for CloudSyncLevelDialog, + * mirroring useMoveToOrgDialog (the two share the same eligibility rule: + * signed into ORG2 Cloud, a cloud org is selected in the sidebar, and the + * session is the owner's own pushable session — only imported teammate copies (pulled from the cloud, + * `importedFrom` set) are excluded; the user's OWN external history (imported + * Claude Code / Cursor / … sessions) IS shareable and has a sync level). + */ +import { atom, useAtom, useStore } from "jotai"; +import { useCallback } from "react"; + +import type { Session } from "@src/store/session/sessionAtom/types"; + +import { org2CloudAuthAtom } from "../org2CloudAuthAtom"; +import { + getSidebarActiveCloudOrg, + org2CloudOrgsAtom, + sidebarActiveCloudOrgIdAtom, +} from "../org2CloudOrgsAtom"; +import { isCloudPushCandidate } from "../org2CloudSyncEngine"; + +/** + * Open-dialog state. Module-level (instead of hook-local useState) because + * the dialog's ONLY production entry is a native Tauri context-menu item, + * which WebDriver cannot click — the `__e2e` cloud bridge drives this atom + * directly for rendered E2E coverage. Behavior is unchanged for the single + * mounted consumer (WorkstationSidebarConnector). + */ +export const cloudSyncLevelSessionAtom = atom(null); +cloudSyncLevelSessionAtom.debugLabel = "cloudSyncLevelSessionAtom"; + +export interface UseCloudSyncLevelDialogResult { + syncLevelSession: Session | null; + isSyncLevelEligible: (session: Session) => boolean; + openSyncLevel: (session: Session) => void; + closeSyncLevel: () => void; +} + +export function useCloudSyncLevelDialog(): UseCloudSyncLevelDialogResult { + // Store read at call time — the gate runs inside a native context-menu + // handler and must see the live roster, never a render-time capture. + const store = useStore(); + const [syncLevelSession, setSyncLevelSession] = useAtom( + cloudSyncLevelSessionAtom + ); + + const isSyncLevelEligible = useCallback( + (session: Session) => { + const activeOrg = getSidebarActiveCloudOrg( + store.get(sidebarActiveCloudOrgIdAtom), + store.get(org2CloudOrgsAtom) + ); + return ( + store.get(org2CloudAuthAtom) !== null && + activeOrg !== null && + isCloudPushCandidate(session) + ); + }, + [store] + ); + + const openSyncLevel = useCallback( + (session: Session) => { + setSyncLevelSession(session); + }, + [setSyncLevelSession] + ); + + const closeSyncLevel = useCallback(() => { + setSyncLevelSession(null); + }, [setSyncLevelSession]); + + return { + syncLevelSession, + isSyncLevelEligible, + openSyncLevel, + closeSyncLevel, + }; +} diff --git a/src/features/Org2Cloud/ImportSharedSessionDialog.tsx b/src/features/Org2Cloud/ImportSharedSessionDialog.tsx new file mode 100644 index 000000000..538fd9d91 --- /dev/null +++ b/src/features/Org2Cloud/ImportSharedSessionDialog.tsx @@ -0,0 +1,104 @@ +/** Paste-a-share-link entry point: parsed token goes to `org2CloudPendingShareAtom`; `CloudShareImportDialog` owns the resolve → import flow (anon-capable). */ +import Modal from "@/src/scaffold/ModalSystem"; +import { useSetAtom } from "jotai"; +import React, { useCallback, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import Button from "@src/components/Button"; +import Input from "@src/components/Input"; + +import { parseCloudShareInput } from "./org2CloudOrgManagement"; +import { org2CloudPendingShareAtom } from "./org2CloudPendingShareAtom"; + +interface ImportSharedSessionDialogProps { + visible: boolean; + onClose: () => void; +} + +const ImportSharedSessionDialog: React.FC = ({ + visible, + onClose, +}) => { + const { t } = useTranslation("navigation"); + const setPendingShare = useSetAtom(org2CloudPendingShareAtom); + const [value, setValue] = useState(""); + const [invalid, setInvalid] = useState(false); + + const handleClose = useCallback(() => { + setValue(""); + setInvalid(false); + onClose(); + }, [onClose]); + + const handlePasteFromClipboard = useCallback(() => { + navigator.clipboard + .readText() + .then((text) => { + if (!text) return; + setValue(text); + setInvalid(false); + }) + .catch(() => undefined); + }, []); + + const handleSubmit = useCallback(() => { + const parsed = parseCloudShareInput(value); + if (!parsed) { + setInvalid(true); + return; + } + setPendingShare(parsed); + handleClose(); + }, [handleClose, setPendingShare, value]); + + return ( + +
+ { + setValue(next); + setInvalid(false); + }} + onKeyDown={(event) => { + if (event.key === "Enter") handleSubmit(); + }} + placeholder={t("cloud.share.importInputPlaceholder")} + errorMessage={ + invalid ? t("cloud.share.importInvalidInput") : undefined + } + autoComplete="off" + spellCheck={false} + data-testid="import-session-input" + /> +
+ + +
+
+
+ ); +}; + +export default ImportSharedSessionDialog; diff --git a/src/features/Org2Cloud/JoinCloudOrgDialog.tsx b/src/features/Org2Cloud/JoinCloudOrgDialog.tsx new file mode 100644 index 000000000..6c58379b0 --- /dev/null +++ b/src/features/Org2Cloud/JoinCloudOrgDialog.tsx @@ -0,0 +1,173 @@ +/** + * JoinCloudOrgDialog — consumer side of the `orgii://cloud/join` invite deep + * link (modeled on `CollabShareImportDialog`): confirmation dialog → + * `accept_invite(sha256(code))` → refresh `org2CloudOrgsAtom` → toast. + * + * The pending atom itself is the dialog state: it stays set while the + * confirmation is open and is consumed (cleared) exactly once on dismiss or + * successful join. Signed-out users get a sign-in CTA that routes to the + * Settings sign-in card; the pending invite SURVIVES that detour — when the + * user returns to the Workstation surface, the dialog re-opens with the + * same code (in-memory only, so an app restart drops it). + * + * The plaintext code never leaves this device: only its sha256 goes to + * `accept_invite` (same code model as invite creation). + */ +import Modal from "@/src/scaffold/ModalSystem"; +import { useAtom } from "jotai"; +import React, { useCallback, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useLocation, useNavigate } from "react-router-dom"; + +import Button from "@src/components/Button"; +import Message from "@src/components/Message"; +import { buildSettingsPath } from "@src/config/mainAppPaths"; +import { ROUTES } from "@src/config/routes"; + +import { commitRefreshedAuth, org2CloudAuthAtom } from "./org2CloudAuthAtom"; +import { ensureFreshSession } from "./org2CloudClient"; +import { acceptCloudInvite } from "./org2CloudManagementClient"; +import { cloudManagementErrorMessage } from "./org2CloudOrgManagement"; +import { useRefetchOrg2CloudOrgs } from "./org2CloudOrgsAtom"; +import { org2CloudPendingInviteAtom } from "./org2CloudPendingInviteAtom"; +import { ensureProjectOrgForCloudOrg } from "./org2CloudProjectOrgAlias"; + +const JoinCloudOrgDialog: React.FC = () => { + const { t } = useTranslation("navigation"); + const navigate = useNavigate(); + const location = useLocation(); + const [pending, setPending] = useAtom(org2CloudPendingInviteAtom); + const [auth, setAuth] = useAtom(org2CloudAuthAtom); + const refetchOrgs = useRefetchOrg2CloudOrgs(); + const [joining, setJoining] = useState(false); + const [error, setError] = useState(null); + + const signedIn = Boolean(auth); + const codeSuffix = pending?.inviteCode.slice(-4) ?? ""; + // SidebarSelector keeps every sidebar (and this dialog) mounted across + // routes, so gate visibility on the Workstation surface — NOT the pending + // atom alone. The sign-in CTA detours to the Settings sign-in card; hiding + // here (while `pending` survives) keeps the modal from covering it, then + // re-opens the dialog once the user is back on the Workstation. + const onWorkstation = location.pathname.startsWith( + ROUTES.workStation.base.path + ); + + const handleClose = useCallback(() => { + // One-shot consume: clears the atom so nothing can replay this link. + setPending(null); + setError(null); + }, [setPending]); + + // Sign-in detour: keep the pending invite so the dialog re-opens once the + // user is back on the Workstation surface after signing in. + const handleOpenSettings = useCallback(() => { + navigate(buildSettingsPath({ section: "collaboration" })); + }, [navigate]); + + const handleJoin = useCallback(async () => { + if (!pending || joining) return; + const current = auth; + if (!current) return; + setJoining(true); + setError(null); + try { + const fresh = await ensureFreshSession(current); + if (!fresh) throw new Error(t("cloud.orgPanel.loadError")); + commitRefreshedAuth(setAuth, current, fresh); + const result = await acceptCloudInvite( + fresh.accessToken, + pending.inviteCode + ); + const orgs = await refetchOrgs({ + until: (items) => items.some((org) => org.orgId === result.orgId), + }); + const joinedOrg = orgs.find((org) => org.orgId === result.orgId); + if (joinedOrg) { + // Project-org alias on join (cloud-parity Phase B); best-effort — + // the sync engine re-ensures it once per start. + try { + await ensureProjectOrgForCloudOrg(joinedOrg); + } catch { + // Non-fatal: the engine's per-org pass self-heals the alias. + } + } + Message.success( + joinedOrg + ? t("cloud.orgManagement.join.joinedToast", { org: joinedOrg.name }) + : t("cloud.orgManagement.join.joinedFallbackToast") + ); + setPending(null); + } catch (caught) { + setError(cloudManagementErrorMessage(caught, t)); + } finally { + setJoining(false); + } + }, [auth, joining, pending, refetchOrgs, setAuth, setPending, t]); + + return ( + +
+
+ {t("cloud.orgManagement.join.prompt")} +
+ +
+
+ {t("cloud.orgManagement.join.codeSuffix", { suffix: codeSuffix })} +
+
+ + {!signedIn ? ( +
+ {t("cloud.orgManagement.join.signInFirst")} +
+ ) : null} + + {error ? ( +
+ {error} +
+ ) : null} + +
+ + {signedIn ? ( + + ) : ( + + )} +
+
+
+ ); +}; + +export default JoinCloudOrgDialog; diff --git a/src/features/Org2Cloud/Org2CloudSection.tsx b/src/features/Org2Cloud/Org2CloudSection.tsx new file mode 100644 index 000000000..49a11b278 --- /dev/null +++ b/src/features/Org2Cloud/Org2CloudSection.tsx @@ -0,0 +1,110 @@ +/** + * "Session Sync" Settings section (cloud design §20.1 + §4.2). + * + * Two tabs: + * 1. Cloud — ORG2 Cloud (managed) with the existing sign-in / + * sign-out control. Sign-in opens the managed cloud login page in the + * SYSTEM browser; the login page finishes with a redirect to + * `orgii://auth/callback#…`, which the OS delivers through the + * deep-link plugin and useDeepLinkHandler completes at the + * always-mounted app root — so sign-in survives this section + * unmounting. + * Includes the agent task runner card (`CloudAgentRunnerCard`, agent-pickup §4 item + * 7) — per-org account/model/mode defaults for comment-task runs; + * hidden until a cloud org exists. + * 2. Self-hosted — the custom ORG2 Cloud backend card (`CloudEndpointCard`, + * cloud-parity Phase C): self-hosting means deploying the SAME stack + * and pointing the app at it. + */ +import { + SECTION_ACTION_GAP_CLASSES, + SectionContainer, + SectionRow, +} from "@/src/modules/shared/layouts/SectionLayout"; +import { openUrl } from "@tauri-apps/plugin-opener"; +import { useAtom } from "jotai"; +import React, { useCallback } from "react"; +import { useTranslation } from "react-i18next"; + +import Button from "@src/components/Button"; +import CloudAgentRunnerCard from "@src/features/Org2Cloud/CloudAgentRunnerCard"; +import CloudEndpointCard from "@src/features/Org2Cloud/CloudEndpointCard"; +import { buildOrg2CloudLoginUrl } from "@src/features/Org2Cloud/config"; +import { org2CloudAuthAtom } from "@src/features/Org2Cloud/org2CloudAuthAtom"; +import { createLogger } from "@src/hooks/logger"; + +const log = createLogger("Org2CloudSection"); + +export const COLLABORATION_TAB_KEYS = { + CLOUD: "cloud", + SELF_HOSTED: "self-hosted", +} as const; + +interface Org2CloudSectionProps { + activeTab?: string; +} + +const Org2CloudSection: React.FC = ({ + activeTab = COLLABORATION_TAB_KEYS.CLOUD, +}) => { + const { t } = useTranslation("navigation"); + const [auth, setAuth] = useAtom(org2CloudAuthAtom); + + const handleSignIn = useCallback(() => { + openUrl(buildOrg2CloudLoginUrl()).catch((error: unknown) => { + log.error("failed to open ORG2 Cloud login in system browser", error); + }); + }, []); + + const handleSignOut = useCallback(() => { + setAuth(null); + }, [setAuth]); + + if (activeTab === COLLABORATION_TAB_KEYS.SELF_HOSTED) { + return ; + } + + return ( + <> + + + {t("cloud.title")} + + {t("cloud.recommendedBadge")} + + + } + description={t("cloud.recommendedDesc")} + align="start" + > +
+ {auth ? ( + + ) : ( + + )} +
+
+
+ + {/* Per-org agent-task runner defaults; hidden until an org exists. */} + + + ); +}; + +export default Org2CloudSection; diff --git a/src/features/Org2Cloud/SessionComments/CommentThreadList.tsx b/src/features/Org2Cloud/SessionComments/CommentThreadList.tsx new file mode 100644 index 000000000..c20f0dd76 --- /dev/null +++ b/src/features/Org2Cloud/SessionComments/CommentThreadList.tsx @@ -0,0 +1,649 @@ +/** + * CommentThreadList — presentational thread list + composer shared by the + * turn-anchored inline panels and the session-level notes dialog (design + * session-comments-design-0707 §4). + * + * PR-review semantics: flat threads (top-level + one reply level), a + * three-state status on thread heads (Active / Resolved / Won't fix), edit + * gated to the author, delete to author/org-admin, tombstones rendered as + * "comment deleted" so reply chains keep their anchor. The anchor itself + * (event id / session-level) is baked into `onAdd` by the caller — this + * component never sees it. + * + * Draft restore (design §4 non-goals): composers clear ONLY on a + * successful add/edit — a failed RPC keeps the text in place and surfaces + * a toast, which is the local equivalent of the `restoreToInputAtom` + * cancel-restore pattern (no cross-component atom needed: the composer + * state never left this component). + * + * Agent surface (2026-07-11 rework): follow-ups run IN PLACE on the owning + * session, so the per-thread task chrome is gone. What remains: the literal + * `@agent ` prefix on the TOP-LEVEL composer creates the pickup task + * silently (comment-first — post verbatim through the untouched add path, + * then create; create is idempotent per comment), `kind='agent_report'` + * replies render as ordinary replies with a tiny agent affix, and a thread + * whose task/run is live shows one minimal "Agent is addressing…" line. + */ +import { Bot, Check, Loader2, Pencil, Trash2 } from "lucide-react"; +import React, { useCallback, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import Button from "@src/components/Button"; +import Message from "@src/components/Message"; +import TextButton from "@src/components/TextButton"; +import Textarea from "@src/components/Textarea"; +import Tooltip from "@src/components/Tooltip"; +import { formatRelativeTime } from "@src/util/time/formatRelativeTime"; + +import { + CLOUD_COMMENT_MAX_BODY_LENGTH, + type CloudCommentResolution, + type CloudSessionComment, +} from "../org2CloudCommentsClient"; +import { + type CommentThread, + getThreadResolution, + isThreadResolved, +} from "../org2CloudSessionCommentsAtom"; +import { useSessionCommentsContext } from "./SessionCommentsContext"; +import { + detectAgentPrefix, + splitAgentMentionBody, +} from "./commentAgentAffordances"; + +export type CommentThreadStatus = "active" | CloudCommentResolution; + +const THREAD_STATUS_OPTIONS: readonly CommentThreadStatus[] = [ + "active", + "resolved", + "wont_fix", +]; + +const THREAD_STATUS_LABEL_KEYS: Record = { + active: "cloud.comments.statusActive", + resolved: "cloud.comments.resolved", + wont_fix: "cloud.comments.wontFix", +}; + +export interface CommentThreadListProps { + threads: CommentThread[]; + viewerUserId: string | null; + viewerIsAdmin: boolean; + /** Hide the top-level composer (e.g. the orphaned "earlier version" + * bucket, where new anchors would be meaningless). Replies stay. */ + showComposer?: boolean; + /** Disable the TOP-LEVEL composer with a tooltip (replay-access gate). */ + composerDisabled?: boolean; + composerDisabledReason?: string; + composerPlaceholder?: string; + emptyLabel?: string; + /** + * Resolves with the created row when the caller's add path returns it + * (context surfaces do) — the `@agent ` prefix needs the new comment's + * id. Undefined = row unknown; the prefix silently skips (comment-first, + * never comment-blocking). + */ + onAdd: ( + body: string, + parentId?: string + ) => Promise; + onEdit: (commentId: string, body: string) => Promise; + onDelete: (commentId: string) => Promise; + onResolve: ( + commentId: string, + resolved: boolean, + resolution?: CloudCommentResolution + ) => Promise; +} + +interface ComposerProps { + placeholder: string; + submitLabel: string; + autoFocus?: boolean; + disabled?: boolean; + onSubmit: (body: string) => Promise; + onCancel?: () => void; + testId?: string; +} + +/** Clears only on success — a failed submit keeps the draft in place. */ +const CommentComposer: React.FC = ({ + placeholder, + submitLabel, + autoFocus = false, + disabled = false, + onSubmit, + onCancel, + testId, +}) => { + const { t } = useTranslation("navigation"); + const [body, setBody] = useState(""); + const [busy, setBusy] = useState(false); + const trimmed = body.trim(); + + const submit = useCallback(async () => { + if (!trimmed || busy || disabled) return; + setBusy(true); + try { + await onSubmit(trimmed); + setBody(""); + } catch { + // Draft restore: the text stays in the composer. + Message.error(t("cloud.comments.addError")); + } finally { + setBusy(false); + } + }, [trimmed, busy, disabled, onSubmit, t]); + + return ( +
+