mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-18 23:32:43 +00:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72b9ea1652 | ||
|
|
7838b534a8 | ||
|
|
ff0171cf7b | ||
|
|
77aff0f4fb | ||
|
|
708f9260b1 | ||
|
|
e34aea206e | ||
|
|
49784fac15 | ||
|
|
4aab00f267 | ||
|
|
de8535c38b | ||
|
|
d67a6f5f6c | ||
|
|
78f7796eb4 |
@@ -20,7 +20,50 @@ env:
|
||||
SOURCE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
|
||||
|
||||
jobs:
|
||||
ensure-release:
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: scripts
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
||||
|
||||
- name: Resolve release tag
|
||||
shell: bash
|
||||
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Ensure GitHub release exists
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG" >/dev/null 2>&1; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
release_args=(
|
||||
release create "$RELEASE_TAG"
|
||||
--repo "${{ github.repository }}"
|
||||
--title "Paseo $RELEASE_TAG"
|
||||
--notes ""
|
||||
--draft
|
||||
)
|
||||
|
||||
if [[ "$IS_PRERELEASE" == "true" ]]; then
|
||||
release_args+=(--prerelease)
|
||||
fi
|
||||
|
||||
if ! gh "${release_args[@]}"; then
|
||||
echo "Release creation raced with another workflow; continuing."
|
||||
node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG" >/dev/null
|
||||
fi
|
||||
|
||||
publish-android-apk:
|
||||
needs: [ensure-release]
|
||||
permissions:
|
||||
contents: write
|
||||
packages: read
|
||||
@@ -36,32 +79,6 @@ jobs:
|
||||
shell: bash
|
||||
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Ensure GitHub release exists
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" >/dev/null 2>&1; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
release_args=(
|
||||
release create "$RELEASE_TAG"
|
||||
--repo "${{ github.repository }}"
|
||||
--title "Paseo $RELEASE_TAG"
|
||||
--notes ""
|
||||
)
|
||||
|
||||
if [[ "$IS_PRERELEASE" == "true" ]]; then
|
||||
release_args+=(--prerelease)
|
||||
fi
|
||||
|
||||
if ! gh "${release_args[@]}"; then
|
||||
echo "Release creation raced with another workflow; continuing."
|
||||
fi
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -129,4 +146,5 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh release upload "$RELEASE_TAG" "${{ steps.artifact.outputs.asset_path }}" --clobber --repo "${{ github.repository }}"
|
||||
release_lookup="$(node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG")"
|
||||
gh release upload "$release_lookup" "${{ steps.artifact.outputs.asset_path }}" --clobber --repo "${{ github.repository }}"
|
||||
|
||||
@@ -75,7 +75,7 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
if gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" > /dev/null 2>&1; then
|
||||
if node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG" > /dev/null 2>&1; then
|
||||
echo "Release $RELEASE_TAG already exists, skipping creation"
|
||||
else
|
||||
prerelease_flag=""
|
||||
@@ -86,8 +86,10 @@ jobs:
|
||||
--repo "${{ github.repository }}" \
|
||||
--title "Paseo $RELEASE_TAG" \
|
||||
--notes "" \
|
||||
--draft \
|
||||
$prerelease_flag || {
|
||||
echo "Release creation raced with another workflow; continuing."
|
||||
node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG" > /dev/null
|
||||
}
|
||||
fi
|
||||
|
||||
@@ -191,7 +193,8 @@ jobs:
|
||||
echo "::error::No release artifacts found in $release_dir"
|
||||
exit 1
|
||||
fi
|
||||
gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}"
|
||||
release_lookup="$(node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG")"
|
||||
gh release upload "$release_lookup" "${files[@]}" --clobber --repo "${{ github.repository }}"
|
||||
|
||||
- name: Upload desktop artifacts to workflow
|
||||
if: env.SHOULD_PUBLISH != 'true'
|
||||
@@ -299,7 +302,8 @@ jobs:
|
||||
echo "::error::No release artifacts found in $release_dir"
|
||||
exit 1
|
||||
fi
|
||||
gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}"
|
||||
release_lookup="$(node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG")"
|
||||
gh release upload "$release_lookup" "${files[@]}" --clobber --repo "${{ github.repository }}"
|
||||
|
||||
- name: Upload desktop artifacts to workflow
|
||||
if: env.SHOULD_PUBLISH != 'true'
|
||||
@@ -404,7 +408,8 @@ jobs:
|
||||
echo "::error::No release artifacts found in $release_dir"
|
||||
exit 1
|
||||
fi
|
||||
gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}"
|
||||
release_lookup="$(node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG")"
|
||||
gh release upload "$release_lookup" "${files[@]}" --clobber --repo "${{ github.repository }}"
|
||||
|
||||
- name: Upload desktop artifacts to workflow
|
||||
if: env.SHOULD_PUBLISH != 'true'
|
||||
@@ -424,7 +429,7 @@ jobs:
|
||||
|
||||
finalize-rollout:
|
||||
needs: [publish-macos, publish-linux, publish-windows]
|
||||
if: ${{ always() && (needs.publish-macos.result == 'success' || needs.publish-macos.result == 'skipped') && (needs.publish-linux.result == 'success' || needs.publish-linux.result == 'skipped') && (needs.publish-windows.result == 'success' || needs.publish-windows.result == 'skipped') && (github.event_name != 'workflow_dispatch' || github.event.inputs.publish != 'false') }}
|
||||
if: ${{ always() && (github.event_name != 'workflow_dispatch' || github.event.inputs.publish != 'false') }}
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
@@ -480,7 +485,7 @@ jobs:
|
||||
name: windows-manifest
|
||||
path: release-manifests/windows-manifest
|
||||
|
||||
- name: Assemble and upload stamped manifests
|
||||
- name: Assemble manifests and publish release
|
||||
if: env.IS_SMOKE_TAG != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -491,6 +496,33 @@ jobs:
|
||||
manifests_dir="$PWD/final"
|
||||
mkdir -p "$manifests_dir"
|
||||
|
||||
expected_manifests=(
|
||||
"${RELEASE_CHANNEL}-mac.yml"
|
||||
"${RELEASE_CHANNEL}-linux.yml"
|
||||
"${RELEASE_CHANNEL}.yml"
|
||||
)
|
||||
release_lookup="$(node ../scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG" --cleanup-duplicates)"
|
||||
existing_assets="$(gh release view "$release_lookup" --repo "${{ github.repository }}" --json assets --jq '.assets[].name')"
|
||||
for manifest_name in "${expected_manifests[@]}"; do
|
||||
if grep -Fxq "$manifest_name" <<< "$existing_assets"; then
|
||||
case "$manifest_name" in
|
||||
"${RELEASE_CHANNEL}-mac.yml") platform_result="${{ needs.publish-macos.result }}" ;;
|
||||
"${RELEASE_CHANNEL}-linux.yml") platform_result="${{ needs.publish-linux.result }}" ;;
|
||||
"${RELEASE_CHANNEL}.yml") platform_result="${{ needs.publish-windows.result }}" ;;
|
||||
esac
|
||||
if [[ "$platform_result" =~ ^(success|skipped)$ ]]; then
|
||||
gh release download "$release_lookup" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--pattern "$manifest_name" \
|
||||
--dir "$manifests_dir"
|
||||
else
|
||||
gh release delete-asset "$release_lookup" "$manifest_name" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--yes
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ "${{ needs.publish-macos.result }}" == "success" ]]; then
|
||||
manifest_name="${RELEASE_CHANNEL}-mac.yml"
|
||||
node ../scripts/merge-mac-manifest.mjs \
|
||||
@@ -515,6 +547,7 @@ jobs:
|
||||
fi
|
||||
timestamp="$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")"
|
||||
node ../scripts/stamp-rollout.mjs --release-date "$timestamp" --rollout-hours "$ROLLOUT_HOURS" "${files[@]}"
|
||||
# shellcheck disable=SC2016
|
||||
ROLLOUT_HOURS_EXPECTED="$ROLLOUT_HOURS" RELEASE_DATE_EXPECTED="$timestamp" node -e '
|
||||
const yaml = require("js-yaml");
|
||||
const fs = require("fs");
|
||||
@@ -536,4 +569,28 @@ jobs:
|
||||
}
|
||||
}
|
||||
' "${files[@]}"
|
||||
gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}"
|
||||
gh release upload "$release_lookup" "${files[@]}" --clobber --repo "${{ github.repository }}"
|
||||
|
||||
failed_builds=()
|
||||
[[ "${{ needs.publish-macos.result }}" =~ ^(success|skipped)$ ]] || failed_builds+=(macOS)
|
||||
[[ "${{ needs.publish-linux.result }}" =~ ^(success|skipped)$ ]] || failed_builds+=(Linux)
|
||||
[[ "${{ needs.publish-windows.result }}" =~ ^(success|skipped)$ ]] || failed_builds+=(Windows)
|
||||
if (( ${#failed_builds[@]} > 0 )); then
|
||||
echo "::error::Desktop builds did not complete for ${failed_builds[*]}; the release remains a draft"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
missing_manifests=()
|
||||
for manifest_name in "${expected_manifests[@]}"; do
|
||||
[[ -f "$manifests_dir/$manifest_name" ]] || missing_manifests+=("$manifest_name")
|
||||
done
|
||||
if (( ${#missing_manifests[@]} > 0 )); then
|
||||
echo "::error::Missing updater manifests: ${missing_manifests[*]}; the release remains a draft"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
publish_args=(release edit "$release_lookup" --tag "$RELEASE_TAG" --draft=false --repo "${{ github.repository }}")
|
||||
if [[ "$IS_PRERELEASE" == "true" ]]; then
|
||||
publish_args+=(--prerelease)
|
||||
fi
|
||||
gh "${publish_args[@]}"
|
||||
|
||||
@@ -15,7 +15,7 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
create_if_missing:
|
||||
description: "Create release if missing (normally only needed for tag events)."
|
||||
description: "Create a draft release if missing (normally only needed for tag events)."
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
@@ -29,7 +29,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
|
||||
@@ -355,13 +355,13 @@ Two workspaces can share the same `cwd` (e.g. a `directory` workspace and a `loc
|
||||
|
||||
| State | Key builder / store | Source |
|
||||
| ---------------------------- | -------------------------------------------------- | ------------------------------------------------------------- |
|
||||
| Review draft comments | `buildReviewDraftKey` / `buildReviewDraftScopeKey` | `packages/app/src/review/store.ts` |
|
||||
| Diff mode override | review-draft scope key (in-memory) | `packages/app/src/review/state.ts` |
|
||||
| Review draft comments | `buildReviewDraftKey` | `packages/app/src/review/store.ts` |
|
||||
| Working diff comparison | `workingDiffComparisonKey` (in-memory) | `packages/app/src/git/working-diff-comparison/state.ts` |
|
||||
| Composer attachments | `buildWorkspaceAttachmentScopeKey` | `packages/app/src/attachments/workspace-attachments-store.ts` |
|
||||
| File explorer nav/open state | `fileExplorer` map keyed `workspace:{workspaceId}` | `packages/app/src/hooks/use-file-explorer-actions.ts` |
|
||||
| File explorer expanded paths | `expandedPathsByWorkspace[workspaceStateKey]` | `packages/app/src/stores/panel-store/state.ts` |
|
||||
|
||||
`diff-pane.tsx` is the canonical wiring site: it passes `{ serverId, cwd }` to the git queries and `{ serverId, workspaceId, cwd }` to the draft/override/attachment scope keys.
|
||||
`diff-pane.tsx` is the canonical wiring site: it passes `{ serverId, cwd }` to the git queries and `{ serverId, workspaceId, cwd }` to the draft/comparison/attachment scope keys.
|
||||
|
||||
**Do not "fix" the sharing away.** Re-keying a directory-backed query by `workspaceId` makes same-`cwd` workspaces diverge (two windows onto the same git tree showing different diffs). Re-keying owned state (drafts, expanded paths) by `cwd` makes them leak between distinct workspaces on the same folder. The `workspaceId`-keyed builders carry a `// workspaceId is opaque; do not parse this key back into a path.` comment — the opaque-id fallback to `cwd` exists only for old payloads without a `workspaceId`, not as a content-sharing mechanism.
|
||||
|
||||
|
||||
@@ -120,6 +120,8 @@ Workspace and chat surfaces use the full width — these are working surfaces, n
|
||||
|
||||
Sections sit apart. `<SettingsSection>` owns its own bottom margin; the next thing is wrapped in another `<SettingsSection>`. The agent-list `sectionHeading` carries the same `marginTop`/`marginBottom` rhythm (`packages/app/src/components/agent-list.tsx:511-517`). Adding `marginBottom` to a section is wrong.
|
||||
|
||||
A section or group explains itself through the `info` prop on `<SettingsSection>` or `<SettingsGroup>` — an info icon beside the header that opens a tooltip (`packages/app/src/screens/settings/settings-info-tip.tsx`). A muted paragraph between the header and the card is wrong: it sits in the section's own gap, so it reads as a second heading rather than as prose belonging to the header. Explanatory copy that describes one row belongs to that row, as `settingsStyles.rowHint` inside the card.
|
||||
|
||||
Cards inside a section sit closer than sections. Rows inside a card touch — only the divider separates them. The rhythm is page → spacious; section → spacious; card → tight.
|
||||
|
||||
Rows have generous vertical padding: roughly 16px of content plus 16px of vertical padding for settings rows, 8–12px for sidebar list items where many rows must fit. Compressing rows below the established density to fit more on the screen is wrong. Too many rows means more cards or more sections, not smaller rows.
|
||||
@@ -248,6 +250,7 @@ New status pills use `<StatusBadge>`. Identity, shortcut, and interactive link b
|
||||
- `fontWeight.medium` on row titles, body text, button labels, badge text, or `<SidebarCallout>` titles. Medium is reserved for the structural-label tier described in §3 — section labels, modal/sheet titles, dense metadata emphasis, and tight action labels. Anything else is `normal`. `<ScreenTitle>` is responsive `400/300` and is never overridden.
|
||||
- `<Pressable>` wrapping `<Text>` to make a button. `<Button>` exists.
|
||||
- Bare `<Text>` for a section header inside settings. `<SettingsSection>` exists.
|
||||
- A muted paragraph between a section header and its card. Section-level explanation is the header's `info` tooltip (§7).
|
||||
- A "Settings" CTA on a detail page. Detail pages are settings; settings is reached from the sidebar, the host entry, or a row's kebab menu.
|
||||
- The word "checkout" in UI strings or identifiers. The term is "workspace".
|
||||
- New color tokens or hardcoded hex outside the palette. The identity color table is the documented exception (§13), not a license.
|
||||
|
||||
@@ -19,6 +19,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
- **MR** — GitLab merge request. UI label for GitLab change requests only; do not use MR for GitHub/Gitea/Forgejo.
|
||||
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. User-facing creation treats it as the `worktree` workspace isolation choice. Code and `paseo.json` retain worktree terminology for git lifecycle implementation. Forbidden: "Checkout" as a product synonym.
|
||||
- **Repository / Remote** — Internal Git observations. They may produce mutable kind, branch, and project-grouping metadata but never the host-local project ID, root, display name, or workspace membership. No UI label.
|
||||
- **Sidebar items** — The top-level rows at the head of the app sidebar: New workspace, History, Search, Schedules, and every plugin sidebar contribution. Order and visibility are one per-device preference (`AppSettings.sidebarNavItems`); all of them may be off. UI: the "Sidebar" section on the Appearance settings page. Code: `packages/app/src/sidebar-nav/model.ts` resolves the list, and both the sidebar (`components/sidebar/sidebar-nav-rows.tsx`) and the settings section (`screens/settings/appearance/sidebar-nav-section.tsx`) read it through `useSidebarNavItems`. Never hardcode one of these rows or add a second place that decides which ones show. Don't confuse with **Explorer sidebar**.
|
||||
- **Explorer sidebar** — The dedicated navigation dock containing the Files and Changes trees. UI: "Explorer sidebar". Code: `packages/app/src/workspace-tabs/explorer-sidebar.ts`; persisted pane identifiers retain the `explorer` spelling. See [explorer-sidebar.md](explorer-sidebar.md).
|
||||
- **Side pane** — An ordinary right-side workspace pane used for content opened beside the user's work. It closes and participates in workspace splits like every other main-host pane. See [explorer-sidebar.md](explorer-sidebar.md#side-pane). Forbidden: "Side panel".
|
||||
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
||||
|
||||
+61
-14
@@ -1,8 +1,9 @@
|
||||
# Plugins
|
||||
|
||||
Local plugins contribute daemon RPCs, native app surfaces, workspace panels, Command Center items,
|
||||
composer pills, app themes, and composer attachment sources from one `index.ts`. Paseo executes the server contribution in a
|
||||
subprocess and evaluates the client contribution in the app runtime.
|
||||
client slash commands, timeline items, composer pills, app themes, and composer attachment sources
|
||||
from one `index.ts`. Paseo executes the server contribution in a subprocess and evaluates the client
|
||||
contribution in the app runtime.
|
||||
|
||||
> **Trust every plugin you add.** `paseo plugin add` and `paseo plugin install` mean “I trust this codebase.” Plugins are unsandboxed: server code and Git preparation commands run with the daemon user's access on the daemon host, and client contributions run inside Paseo. The repository's dependencies and future updates are part of that trust decision. With `--host`, preparation runs on that remote daemon host.
|
||||
|
||||
@@ -209,13 +210,13 @@ When the same plugin contribution exists on multiple hosts, Paseo shows it once
|
||||
adds a host picker to the screen header. The selected host supplies the bundle, RPC transport, and
|
||||
query cache. Plugin code cannot address another host.
|
||||
|
||||
Workspace panels and Command Center items remain client contributions. The daemon transports their
|
||||
compiled bundle without interpreting placement or callbacks. Panel props contain workspace and agent
|
||||
IDs. Required-selector hooks read normalized client state synchronously and use shallow equality, so a
|
||||
panel does not subscribe to fields it does not render. Command callbacks materialize their snapshots
|
||||
only when invoked. Contribution discovery and panel opening never fetch active context through plugin
|
||||
RPC. Snapshot DTOs are deeply readonly and frozen at runtime so plugin code cannot mutate normalized
|
||||
app state or a memoized selection. Panels use one persisted
|
||||
Workspace panels, Command Center items, and client slash commands are client contributions. The
|
||||
daemon transports their compiled bundle without interpreting placement or callbacks. Panel props
|
||||
contain workspace and agent IDs. Required-selector hooks read normalized client state synchronously
|
||||
and use shallow equality, so a panel does not subscribe to fields it does not render. Command
|
||||
callbacks materialize their snapshots only when invoked. Contribution discovery and panel opening
|
||||
never fetch active context through plugin RPC. Snapshot DTOs are deeply readonly and frozen at
|
||||
runtime so plugin code cannot mutate normalized app state or a memoized selection. Panels use one persisted
|
||||
`plugin` workspace-tab target, so reload, disable, removal, and restoration resolve through the
|
||||
current installed-plugin catalog. A missing contribution renders unavailable inside the tab.
|
||||
Panels declare `locations: ["workspace", "explorer"]` to opt into Explorer hosting; omission means
|
||||
@@ -275,20 +276,66 @@ the host, or unloading the app tears down the contribution.
|
||||
## Contribute timeline items
|
||||
|
||||
Timeline transformers and renderers are client contributions. The daemon's canonical rows and
|
||||
built-in projection stay unchanged. The app transforms fetched projected history before building
|
||||
its render model. A matching live event requests a fresh projected tail, so lifecycle deltas are
|
||||
collapsed before the transformer replaces anything.
|
||||
built-in projection stay unchanged. The app transforms each source item while building the render
|
||||
model, for both fetched history and live events. The input includes `phase: "streaming" | "complete"`.
|
||||
Paseo memoizes by source-item reference and derives every replacement ID from the source identity, so
|
||||
streaming updates preserve mounted component identity.
|
||||
|
||||
`query.itemType` selects one public `AgentTimelineItem.type`. The callback owns any detailed
|
||||
recognition and returns plain plugin item objects. `undefined` keeps the source item, `items`
|
||||
replaces it, and an empty array removes it. Output `data` must be JSON-compatible. Paseo adds the
|
||||
runtime plugin ID, preserves the source timeline cursor, validates renderer data with its Zod
|
||||
schema, and mounts the component inside the normal plugin runtime and error boundary.
|
||||
runtime plugin ID, preserves the source timeline cursor and identity, validates renderer data with
|
||||
its Zod schema, and mounts the component inside the normal plugin runtime and error boundary. An
|
||||
optional output `id` distinguishes several stable replacements from the same source item; its output
|
||||
index is the default.
|
||||
|
||||
Transformers run synchronously and must be deterministic. When several transformers match, the
|
||||
first one that returns a result owns that source item. Plugin and registration ordering is stable.
|
||||
See `plugin-examples/timeline-items` for the complete contract.
|
||||
|
||||
A plugin subprocess can also append a canonical plugin row from a server handler:
|
||||
|
||||
```ts
|
||||
await paseo.agents.ref(agentId).timeline.append({
|
||||
type: "plugin",
|
||||
id: "review",
|
||||
kind: "review-result",
|
||||
version: 1,
|
||||
data: { status: "ready" },
|
||||
});
|
||||
```
|
||||
|
||||
The daemon stamps the runtime `pluginId`; plugin code never supplies it. Reusing the same `id`
|
||||
replaces the previous row from that plugin on live clients and fresh timeline fetches. The row stays
|
||||
in canonical history and renders an unavailable placeholder when its renderer is not installed.
|
||||
Serialized `data` must be at most 64 KiB; the daemon rejects a larger append instead of storing a
|
||||
payload that cannot be rendered intact. The daemon advertises this RPC through
|
||||
`server_info.features.pluginTimelineItems`.
|
||||
|
||||
## Contribute client slash commands
|
||||
|
||||
`addClientSlashCommand` registers an agent- or workspace-context command in the composer. The
|
||||
callback runs in the app, receives the trimmed text after the command name as `args`, and receives
|
||||
the same `paseo`, `rpc`, `openSurface`, workspace, agent, and `openPanel` capabilities as the matching
|
||||
Command Center callback.
|
||||
|
||||
```ts
|
||||
plugin.addClientSlashCommand({
|
||||
name: "review",
|
||||
description: "Run the review bot",
|
||||
argumentHint: "[scope]",
|
||||
context: "agent",
|
||||
async onSubmit({ args, agent, rpc }) {
|
||||
await rpc(startReview, { agentId: agent.id, scope: args });
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Paseo owns the autocomplete row, input clearing, and error toast. It never sends the command text to
|
||||
the agent. Built-in client commands win name and alias collisions, plugin commands win
|
||||
provider-command collisions, and the first plugin in stable catalog order wins collisions between
|
||||
plugins. Plugin slash commands do not run when the composer has attachments.
|
||||
|
||||
## Contribute composer attachments
|
||||
|
||||
Register a declarative attachment source backed by a plugin RPC. Paseo owns the attachment menu,
|
||||
|
||||
+30
-7
@@ -112,7 +112,7 @@ npm run release:patch
|
||||
npm run release:minor
|
||||
```
|
||||
|
||||
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag. The tag push triggers `Desktop Release`, `Android APK Release`, `Docker`, and `Release Notes Sync` on GitHub Actions. EAS picks up the same tag via the EAS GitHub app and starts the iOS + Android store builds in parallel (see "Mobile builds (EAS)" below) — there is no mobile-release workflow under `.github/workflows`.
|
||||
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag. The tag push triggers `Desktop Release`, `Android APK Release`, `Docker`, and `Release Notes Sync` on GitHub Actions. The workflows create the GitHub Release as a draft while builds and release-note sync run. EAS picks up the same tag via the EAS GitHub app and starts the iOS + Android store builds in parallel (see "Mobile builds (EAS)" below) — there is no mobile-release workflow under `.github/workflows`.
|
||||
|
||||
After the stable release succeeds, move npm's `beta` pointer to the new stable
|
||||
version for every published package. This changes dist-tags only; do not
|
||||
@@ -180,10 +180,10 @@ The rollout is driven by a `rolloutHours` field stamped into the GitHub Release
|
||||
|
||||
Desktop release builds now publish in two phases:
|
||||
|
||||
- Platform build jobs upload the installers/packages (`.dmg`, `.zip`, `.exe`, `.AppImage`, etc.) to the GitHub release.
|
||||
- The final job merges/stamps the manifests and uploads all `.yml` files only after they already contain the final `releaseDate` and `rolloutHours`.
|
||||
- The GitHub Release stays a draft while platform build jobs upload the installers/packages (`.dmg`, `.zip`, `.exe`, `.AppImage`, etc.).
|
||||
- The final job merges and stamps every channel manifest, uploads them with the final `releaseDate` and `rolloutHours`, then publishes the GitHub Release.
|
||||
|
||||
Updater clients only discover a release through those `.yml` manifests, so there is no silent 100% admission window before rollout metadata is present.
|
||||
Drafts do not appear in GitHub's releases feed. Updater clients continue to see the previous complete release until all three manifests are available. If a desktop build or manifest upload fails, the new release stays a draft.
|
||||
|
||||
### Default behavior
|
||||
|
||||
@@ -375,7 +375,7 @@ The GitHub Release body is populated automatically by the `Release Notes Sync` w
|
||||
- The default download target only moves when you publish the final stable release tag like `v0.1.41`.
|
||||
- The public `/changelog` page renders `CHANGELOG.md` as-is, so the in-flight `-beta.N` entry shows there once it lands on `main` — that's intended, it's where beta users check what's coming. Only the **default download target** stays pinned to the latest stable; the download links read GitHub's releases API, not the changelog, so a `-beta.N` heading on top never affects them.
|
||||
- The download page's "What's new" link deep-links the **minor group** anchor (`/changelog#release-0.3`), not the exact entry: promotion collapses the beta entries into one stable entry, so the minor group remains the durable target. A version with no entry in the bundled changelog — a tag whose changelog commit hasn't redeployed the site yet — links the plain `/changelog` instead of a dead anchor.
|
||||
- The website itself is deployed by `Deploy Website` (Cloudflare Workers), which redeploys on `release: published` for non-prerelease releases and on pushes to `main` that touch `CHANGELOG.md` or `packages/website/**`.
|
||||
- The website itself is deployed by `Deploy Website` (Cloudflare Workers), which redeploys on the `release: published` event emitted when a stable draft is published and on pushes to `main` that touch `CHANGELOG.md` or `packages/website/**`. Its job condition excludes beta prereleases.
|
||||
|
||||
## Fixing a failed release build
|
||||
|
||||
@@ -404,6 +404,13 @@ you want to build. Reusing the same tag name is expected: move it with
|
||||
`git tag -f ...` and push it with `--force` so the workflow rebuilds the commit
|
||||
you actually want.
|
||||
|
||||
A failed desktop build leaves the GitHub Release as a draft. `finalize-rollout`
|
||||
uploads manifests from successful platforms before it fails. A later
|
||||
single-platform retry reuses those manifests, stamps the complete set with one
|
||||
release date, and publishes the draft. Use `desktop-vX.Y.Z` when more than one
|
||||
platform failed. A `workflow_dispatch` rebuild with publishing enabled follows
|
||||
the same path against the existing draft.
|
||||
|
||||
Prefer a tag push over `workflow_dispatch` when rebuilding desktop or APK
|
||||
release assets. Prefer Docker workflow dispatch when rebuilding only the Docker
|
||||
image.
|
||||
@@ -433,6 +440,22 @@ This ensures the checkout ref matches the actual code on `main` with the fix inc
|
||||
- `desktop-macos-vX.Y.Z`, `desktop-linux-vX.Y.Z`, and `desktop-windows-vX.Y.Z` rebuild only that desktop platform
|
||||
- `android-vX.Y.Z` rebuilds the Android APK release only
|
||||
|
||||
If you decide to publish a release without working desktop builds, inspect its
|
||||
assets first, then publish it manually:
|
||||
|
||||
```bash
|
||||
RELEASE_LOOKUP=$(node scripts/github-release.mjs --repo getpaseo/paseo --tag vX.Y.Z)
|
||||
gh release view "$RELEASE_LOOKUP" --json isDraft,isPrerelease,assets
|
||||
gh release edit "$RELEASE_LOOKUP" --tag vX.Y.Z --draft=false
|
||||
|
||||
# Keep a beta marked as a prerelease:
|
||||
RELEASE_LOOKUP=$(node scripts/github-release.mjs --repo getpaseo/paseo --tag vX.Y.Z-beta.N)
|
||||
gh release edit "$RELEASE_LOOKUP" --tag vX.Y.Z-beta.N --draft=false --prerelease
|
||||
```
|
||||
|
||||
This bypasses the updater-manifest guarantee. Use it only when the release is
|
||||
intentionally unavailable to desktop updater clients.
|
||||
|
||||
## Notes
|
||||
|
||||
- `version:all:*` bumps root + syncs workspace versions and `@getpaseo/*` dependency versions
|
||||
@@ -585,7 +608,7 @@ Each beta entry records what its testers receive. Promotion produces the single
|
||||
- [ ] `npm run release:beta:patch`, `npm run release:beta:minor`, or `npm run release:beta:next` completes successfully
|
||||
- [ ] Every GitHub Actions run for the complete release commit and tag is green
|
||||
- [ ] npm shows the version under the `beta` dist-tag, not `latest`
|
||||
- [ ] The GitHub prerelease exists with the changelog body and every expected macOS, Linux, Windows, and Android APK asset
|
||||
- [ ] The GitHub prerelease was published only after the three beta manifests were uploaded, and it has the changelog body and every expected macOS, Linux, Windows, and Android APK asset
|
||||
- [ ] GitHub `Desktop Release` workflow for the `v*-beta.N` tag is green
|
||||
- [ ] The GitHub prerelease contains `beta-mac.yml`, `beta-linux.yml`, and `beta.yml`
|
||||
- [ ] GitHub `Android APK Release` workflow for the same tag is green
|
||||
@@ -608,7 +631,7 @@ Each beta entry records what its testers receive. Promotion produces the single
|
||||
- [ ] `npm run release:patch`, `npm run release:minor`, or `npm run release:promote` completes successfully
|
||||
- [ ] Every GitHub Actions run for the complete release commit and tag is green
|
||||
- [ ] Move npm's `beta` dist-tag to the new stable version for every published package and verify both `latest` and `beta` resolve to it
|
||||
- [ ] The published GitHub Release exists with the changelog body and every expected macOS, Linux, Windows, and Android APK asset
|
||||
- [ ] The GitHub Release was published only after the three stable manifests were uploaded, and it has the changelog body and every expected macOS, Linux, Windows, and Android APK asset
|
||||
- [ ] GitHub `Desktop Release` workflow for the `v*` tag is green
|
||||
- [ ] The GitHub Release contains `latest-mac.yml`, `latest-linux.yml`, and `latest.yml`
|
||||
- [ ] GitHub `Android APK Release` workflow for the same tag is green
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-j3lk7o4W10hcXeNIN3kGlqSHn8aCr+GCDkKYZNXNSrw=
|
||||
sha256-0hOGev0HglOQmofzPQMfiWh1opg6cpiEgsfK22AKcGk=
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readFile, unlink, writeFile } from "node:fs/promises";
|
||||
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { type Locator, type Page } from "@playwright/test";
|
||||
import { buildHostWorkspaceRoute, buildSettingsSectionRoute } from "../../src/utils/host-routes";
|
||||
@@ -312,6 +312,7 @@ test("an empty Changes comparison links to the populated comparison", async ({ p
|
||||
await page.getByTestId("changes-diff-mode-uncommitted").click();
|
||||
|
||||
await expect(tree.getByText("No uncommitted changes", { exact: true })).toBeVisible();
|
||||
await expect(panel.getByText("No uncommitted changes", { exact: true })).toBeVisible();
|
||||
const seeCommitted = tree.getByRole("button", { name: "See committed changes" });
|
||||
await expect(seeCommitted).toBeVisible();
|
||||
await seeCommitted.click();
|
||||
@@ -319,6 +320,21 @@ test("an empty Changes comparison links to the populated comparison", async ({ p
|
||||
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("committed-only.ts, +1, -0");
|
||||
});
|
||||
|
||||
test("Changes comparison controls the working diff and tree selection focuses its file", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await createWorkspaceWithScrollableComparisons();
|
||||
await openWorkspaceChangesSurface(page, workspace);
|
||||
|
||||
await selectChangesComparison(page, "Committed");
|
||||
await expectWorkingComparisonFiles(page, "committed");
|
||||
await selectChangedFileAndExpectFocused(page, "committed/50-target.ts");
|
||||
|
||||
await selectChangesComparison(page, "Uncommitted");
|
||||
await expectWorkingComparisonFiles(page, "uncommitted");
|
||||
await selectChangedFileAndExpectFocused(page, "uncommitted/50-target.ts");
|
||||
});
|
||||
|
||||
test("changes file actions open below the right-click without a reserved kebab", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -1466,6 +1482,46 @@ async function createWorkspaceWithCommittedDiff(): Promise<DirtyWorkspace> {
|
||||
return { id: created.workspace.id, repoPath: repo.path };
|
||||
}
|
||||
|
||||
async function createWorkspaceWithScrollableComparisons(): Promise<DirtyWorkspace> {
|
||||
const repo = await createTempGitRepo("changes-comparison-focus-", {
|
||||
files: [{ path: "tracked.ts", content: "export const tracked = true;\n" }],
|
||||
});
|
||||
const client = await connectSeedClient();
|
||||
cleanupTasks.push({
|
||||
run: async () => {
|
||||
await client.close().catch(() => undefined);
|
||||
await repo.cleanup().catch(() => undefined);
|
||||
},
|
||||
});
|
||||
|
||||
const longFile = Array.from(
|
||||
{ length: 120 },
|
||||
(_, index) => `export const line${index} = ${index};`,
|
||||
).join("\n");
|
||||
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repo.path });
|
||||
await writeComparisonFiles(repo.path, "committed", longFile);
|
||||
execFileSync("git", ["add", "committed"], { cwd: repo.path });
|
||||
execFileSync("git", ["commit", "-m", "Add committed comparison files"], { cwd: repo.path });
|
||||
await writeComparisonFiles(repo.path, "uncommitted", longFile);
|
||||
|
||||
const created = await client.createWorkspace({ source: { kind: "directory", path: repo.path } });
|
||||
if (!created.workspace) throw new Error(created.error ?? "Failed to create comparison workspace");
|
||||
return { id: created.workspace.id, repoPath: repo.path };
|
||||
}
|
||||
|
||||
async function writeComparisonFiles(
|
||||
repoPath: string,
|
||||
directory: "committed" | "uncommitted",
|
||||
longFile: string,
|
||||
): Promise<void> {
|
||||
await mkdir(path.join(repoPath, directory), { recursive: true });
|
||||
await Promise.all([
|
||||
writeFile(path.join(repoPath, directory, "00-before.ts"), `${longFile}\n`),
|
||||
writeFile(path.join(repoPath, directory, "50-target.ts"), "export const target = true;\n"),
|
||||
writeFile(path.join(repoPath, directory, "99-after.ts"), `${longFile}\n`),
|
||||
]);
|
||||
}
|
||||
|
||||
async function createWorkspaceWithExactSelectionDiff(content: string): Promise<DirtyWorkspace> {
|
||||
const repo = await createTempGitRepo("changes-canvas-selection-", {
|
||||
files: [{ path: "src/selection.ts", content: "" }],
|
||||
@@ -1583,6 +1639,54 @@ async function openChangesInVisibleExplorer(page: Page): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
async function selectChangesComparison(
|
||||
page: Page,
|
||||
comparison: "Committed" | "Uncommitted",
|
||||
): Promise<void> {
|
||||
const tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
|
||||
await tree.getByTestId("changes-diff-status-trigger").click();
|
||||
await page.getByTestId(`changes-diff-mode-${comparison.toLowerCase()}`).click();
|
||||
await expect(tree.getByTestId("changes-diff-status-trigger")).toContainText(comparison);
|
||||
}
|
||||
|
||||
async function expectWorkingComparisonFiles(
|
||||
page: Page,
|
||||
comparison: "committed" | "uncommitted",
|
||||
): Promise<void> {
|
||||
const tree = changesTree(page);
|
||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
const paths = ["00-before.ts", "50-target.ts", "99-after.ts"];
|
||||
await expect(tree.locator('[data-testid^="diff-tree-file-"][data-testid$="-name"]')).toHaveText(
|
||||
paths,
|
||||
);
|
||||
for (const fileName of paths) {
|
||||
await expect(diffHeaderForPath(panel, `${comparison}/${fileName}`)).toBeAttached();
|
||||
}
|
||||
}
|
||||
|
||||
async function selectChangedFileAndExpectFocused(page: Page, filePath: string): Promise<void> {
|
||||
const tree = changesTree(page);
|
||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
const scroller = panel.getByTestId("git-diff-scroll");
|
||||
await scroller.evaluate((element) => {
|
||||
element.scrollTop = 0;
|
||||
element.dispatchEvent(new Event("scroll", { bubbles: false }));
|
||||
});
|
||||
|
||||
await tree.getByText(path.basename(filePath), { exact: true }).click();
|
||||
const header = diffHeaderForPath(panel, filePath);
|
||||
await expect(header).toBeVisible();
|
||||
await expect.poll(() => scroller.evaluate((element) => element.scrollTop)).toBeGreaterThan(0);
|
||||
|
||||
const [scrollBounds, headerBounds] = await Promise.all([
|
||||
scroller.boundingBox(),
|
||||
header.boundingBox(),
|
||||
]);
|
||||
if (!scrollBounds || !headerBounds)
|
||||
throw new Error("Focused diff header geometry is unavailable");
|
||||
expect(headerBounds.y - scrollBounds.y).toBeCloseTo(0, 0);
|
||||
}
|
||||
|
||||
async function expectExpandedMountedTabDiff(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("diff-file-0-body")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId("git-diff-canvas")).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
@@ -35,6 +35,7 @@ test("keeps a Mermaid diagram rendered while its message streams, completes, and
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
let diagramTurnCompleted!: Promise<void>;
|
||||
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "mermaid-streaming-",
|
||||
@@ -48,15 +49,16 @@ test("keeps a Mermaid diagram rendered while its message streams, completes, and
|
||||
await test.step("Open the conversation and request a diagram", async () => {
|
||||
await openAgentRoute(page, agent);
|
||||
await requestDiagram(agent);
|
||||
diagramTurnCompleted = waitForDiagramTurnToComplete(agent);
|
||||
});
|
||||
|
||||
await test.step("The diagram renders and stays rendered while tokens arrive", async () => {
|
||||
await expectDiagramWithLabels(page, ["Start", "Middle"]);
|
||||
await expectDiagramRemainsRenderedWhileStreaming(page);
|
||||
await expectDiagramRemainsRenderedWhileStreaming(page, diagramTurnCompleted);
|
||||
});
|
||||
|
||||
await test.step("The completed diagram shows the final streamed content", async () => {
|
||||
await waitForDiagramTurnToComplete(agent);
|
||||
await diagramTurnCompleted;
|
||||
await expectCompletedDiagram(page, ["Start", "Done", "Release"]);
|
||||
});
|
||||
|
||||
|
||||
@@ -27,7 +27,10 @@ test("chooses a metadata model and can return to automatic selection", async ({
|
||||
}, testInfo) => {
|
||||
await openMetadataGenerationSettings(page);
|
||||
|
||||
await expect(page.getByText("Workspace titles", { exact: false })).toBeVisible();
|
||||
// The section explains itself through the header's info tooltip, not a paragraph.
|
||||
await expect(page.getByTestId("metadata-generation-settings-info")).toHaveAccessibleName(
|
||||
"About Metadata generation",
|
||||
);
|
||||
await expect(page.getByRole("button", { name: "Automatic", exact: true })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
|
||||
@@ -12,10 +12,7 @@ import { buildAgentRoute } from "../support/helpers/mock-agent";
|
||||
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import {
|
||||
closeMobileAgentSidebar,
|
||||
expectMobileAgentSidebarHidden,
|
||||
} from "../support/helpers/sidebar";
|
||||
import { expectMobileAgentSidebarHidden } from "../support/helpers/sidebar";
|
||||
import {
|
||||
switchWorkspaceViaSidebar,
|
||||
waitForWorkspaceInSidebar,
|
||||
@@ -159,7 +156,7 @@ async function runCommand(page: Page, title: string): Promise<void> {
|
||||
|
||||
async function openCompactSidebar(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Open menu", exact: true }).click();
|
||||
await expect(page.getByTestId("sidebar-command-center-search")).toBeVisible();
|
||||
await expect(page.getByTestId("sidebar-search")).toBeVisible();
|
||||
}
|
||||
|
||||
async function capture(page: Page, testInfo: TestInfo, name: string): Promise<void> {
|
||||
@@ -331,8 +328,9 @@ test.describe("plugin workspace panels and Command Center", () => {
|
||||
await page.waitForURL(isSettledWorkspaceUrl, { timeout: 60_000 });
|
||||
await expect(page.getByRole("button", { name: "Open composer review" })).toHaveCount(0);
|
||||
await openCompactSidebar(page);
|
||||
// The sidebar's Search row dismisses the compact sidebar on its way to the
|
||||
// command center, so nothing has to close it after the command runs.
|
||||
await runCommand(page, "Open plugin agent");
|
||||
await closeMobileAgentSidebar(page);
|
||||
await expectMobileAgentSidebarHidden(page);
|
||||
await expect(page.getByText(`Agent bridge ${agent.id}`)).toBeVisible();
|
||||
await expect(
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import {
|
||||
expectSidebarItemHidden,
|
||||
expectSidebarNavSettingsOrder,
|
||||
expectSidebarNavSettingsRow,
|
||||
expectSidebarOrder,
|
||||
expectStoredSidebarNav,
|
||||
leaveSettings,
|
||||
moveSidebarNavItemUp,
|
||||
openSidebarNavSettings,
|
||||
seedSidebarNavPreferences,
|
||||
setSidebarNavItemVisible,
|
||||
} from "../support/helpers/sidebar-nav-settings";
|
||||
|
||||
test.describe("Sidebar items in Appearance settings", () => {
|
||||
test("owner reorders and hides top-level sidebar items", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
|
||||
await test.step("the sidebar starts in the default order", async () => {
|
||||
await expectSidebarOrder(page, ["new-workspace", "history", "search", "schedules"]);
|
||||
});
|
||||
|
||||
await test.step("the Sidebar section lists every item in the same order", async () => {
|
||||
await openSidebarNavSettings(page);
|
||||
// The section explains itself through the header's info tooltip, not a paragraph.
|
||||
await expect(page.getByTestId("sidebar-nav-section-info")).toHaveAccessibleName(
|
||||
"About Sidebar",
|
||||
);
|
||||
await expectSidebarNavSettingsOrder(page, [
|
||||
"new-workspace",
|
||||
"history",
|
||||
"search",
|
||||
"schedules",
|
||||
]);
|
||||
await expectSidebarNavSettingsRow(page, {
|
||||
key: "history",
|
||||
label: "History",
|
||||
visible: true,
|
||||
});
|
||||
// Items with a keyboard shortcut badge it next to their name. Chords render
|
||||
// with Ctrl off macOS, which is what the browser project runs on.
|
||||
await expect(
|
||||
page.getByTestId("sidebar-nav-item-new-workspace").getByText("Ctrl+N", { exact: true }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("moving Schedules up twice lifts it above History", async () => {
|
||||
await moveSidebarNavItemUp(page, "schedules");
|
||||
await expectSidebarNavSettingsOrder(page, [
|
||||
"new-workspace",
|
||||
"history",
|
||||
"schedules",
|
||||
"search",
|
||||
]);
|
||||
await moveSidebarNavItemUp(page, "schedules");
|
||||
await expectSidebarNavSettingsOrder(page, [
|
||||
"new-workspace",
|
||||
"schedules",
|
||||
"history",
|
||||
"search",
|
||||
]);
|
||||
|
||||
await leaveSettings(page);
|
||||
await expectSidebarOrder(page, ["new-workspace", "schedules", "history", "search"]);
|
||||
});
|
||||
|
||||
await test.step("turning History off removes it from the sidebar", async () => {
|
||||
await openSidebarNavSettings(page);
|
||||
await setSidebarNavItemVisible(page, "history", false);
|
||||
await expectStoredSidebarNav(page, [
|
||||
{ key: "new-workspace", visible: true },
|
||||
{ key: "schedules", visible: true },
|
||||
{ key: "history", visible: false },
|
||||
{ key: "search", visible: true },
|
||||
]);
|
||||
|
||||
await leaveSettings(page);
|
||||
await expectSidebarItemHidden(page, "history");
|
||||
await expectSidebarOrder(page, ["new-workspace", "schedules", "search"]);
|
||||
});
|
||||
|
||||
await test.step("the sidebar keeps that shape across a reload", async () => {
|
||||
await page.reload();
|
||||
await expectSidebarItemHidden(page, "history");
|
||||
await expectSidebarOrder(page, ["new-workspace", "schedules", "search"]);
|
||||
});
|
||||
});
|
||||
|
||||
test("renders no top-level items when every one is turned off", async ({ page }) => {
|
||||
await seedSidebarNavPreferences(page, [
|
||||
{ key: "new-workspace", visible: false },
|
||||
{ key: "history", visible: false },
|
||||
{ key: "search", visible: false },
|
||||
{ key: "schedules", visible: false },
|
||||
]);
|
||||
await gotoAppShell(page);
|
||||
|
||||
// The sidebar itself still renders; only its top-level nav items are gone.
|
||||
await expect(page.locator('[data-testid="sidebar-settings"]:visible')).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expectSidebarItemHidden(page, "new-workspace");
|
||||
await expectSidebarItemHidden(page, "history");
|
||||
await expectSidebarItemHidden(page, "search");
|
||||
await expectSidebarItemHidden(page, "schedules");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { Locator } from "@playwright/test";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { createIdleAgent } from "../support/helpers/archive-tab";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
|
||||
|
||||
const TITLE_MAX_LENGTH = 80;
|
||||
|
||||
function normalizedTitle(title: string): string {
|
||||
return title.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function expectedTooltipTitle(title: string): string {
|
||||
const singleLineTitle = normalizedTitle(title);
|
||||
return singleLineTitle.length <= TITLE_MAX_LENGTH
|
||||
? singleLineTitle
|
||||
: `${singleLineTitle.slice(0, TITLE_MAX_LENGTH - 1).trimEnd()}…`;
|
||||
}
|
||||
|
||||
async function openAgent(page: Page, agent: { id: string; workspaceId: string }): Promise<void> {
|
||||
await page.goto(buildHostAgentDetailRoute(getServerId(), agent.id, agent.workspaceId));
|
||||
await page.waitForURL(
|
||||
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
}
|
||||
|
||||
async function openAgentTabTooltip(page: Page, agentId: string, title: string): Promise<Locator> {
|
||||
await page.getByRole("button", { name: normalizedTitle(title), exact: true }).hover();
|
||||
const tooltip = page.getByTestId(`workspace-tab-tooltip-agent_${agentId}`);
|
||||
await expect(tooltip).toBeVisible({ timeout: 10_000 });
|
||||
return tooltip;
|
||||
}
|
||||
|
||||
async function expectTwoRowAgentSummary(
|
||||
tooltip: Locator,
|
||||
input: { title: string; agentId: string },
|
||||
): Promise<void> {
|
||||
const title = tooltip.getByText(expectedTooltipTitle(input.title), { exact: true });
|
||||
const id = tooltip.getByText(input.agentId.slice(0, 7), { exact: true });
|
||||
const activity = tooltip.getByText(/^(just now|\d+[mhd] ago|[A-Z][a-z]{2} \d{1,2})$/);
|
||||
|
||||
await expect(title).toBeVisible();
|
||||
await expect(id).toBeVisible();
|
||||
await expect(tooltip.getByText("·", { exact: true })).toBeVisible();
|
||||
await expect(activity).toBeVisible();
|
||||
expect((await title.boundingBox())?.y).toBeLessThan((await id.boundingBox())?.y ?? 0);
|
||||
}
|
||||
|
||||
test.describe("Workspace agent tab tooltip", () => {
|
||||
test("summarizes a multiline agent title above its ID and last activity", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const workspace = await seedWorkspace({ repoPrefix: "workspace-agent-tooltip-" });
|
||||
|
||||
try {
|
||||
const title = [
|
||||
"Short first line",
|
||||
"followed by a much longer continuation that should be joined before the complete title is truncated to a useful tooltip length",
|
||||
].join("\n");
|
||||
const agent = await createIdleAgent(workspace.client, {
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title,
|
||||
});
|
||||
|
||||
await openAgent(page, agent);
|
||||
const tooltip = await openAgentTabTooltip(page, agent.id, title);
|
||||
|
||||
await expectTwoRowAgentSummary(tooltip, { title, agentId: agent.id });
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -58,7 +58,7 @@ export async function observeCommandCenterScroll(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
export async function openCommandCenterWithKeyboard(page: Page): Promise<Locator> {
|
||||
await expect(page.getByTestId("sidebar-command-center-search")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId("sidebar-search")).toBeVisible({ timeout: 30_000 });
|
||||
await page.keyboard.press("Meta+K");
|
||||
const panel = page.getByTestId("command-center-panel");
|
||||
await expect(panel).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
@@ -2,7 +2,7 @@ import { expect, type Locator, type Page } from "@playwright/test";
|
||||
|
||||
// Opens the command center / global search palette from the sidebar and returns its panel.
|
||||
export async function openCommandCenter(page: Page): Promise<Locator> {
|
||||
await page.getByTestId("sidebar-command-center-search").click();
|
||||
await page.getByTestId("sidebar-search").click();
|
||||
const panel = page.getByTestId("command-center-panel");
|
||||
await expect(panel).toBeVisible({ timeout: 30_000 });
|
||||
return panel;
|
||||
|
||||
@@ -25,13 +25,16 @@ export async function expectDiagramWithLabels(
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectDiagramRemainsRenderedWhileStreaming(page: Page): Promise<void> {
|
||||
const { diagram, svg } = renderedDiagram(page);
|
||||
const samples = 80;
|
||||
for (let sample = 0; sample < samples; sample += 1) {
|
||||
await expect(diagram).toBeVisible({ timeout: 100 });
|
||||
await expect(svg).toBeVisible({ timeout: 500 });
|
||||
await page.waitForTimeout(25);
|
||||
export async function expectDiagramRemainsRenderedWhileStreaming(
|
||||
page: Page,
|
||||
completion: Promise<void>,
|
||||
): Promise<void> {
|
||||
const diagram = page.getByRole("img", { name: DIAGRAM_NAME }).last();
|
||||
const completed = completion.then(() => true);
|
||||
for (;;) {
|
||||
expect(await diagram.isVisible()).toBe(true);
|
||||
const didComplete = await Promise.race([completed, page.waitForTimeout(16).then(() => false)]);
|
||||
if (didComplete) return completion;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ async function createRunningMockAgent(
|
||||
}
|
||||
|
||||
async function openCommandCenter(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Open command center" }).click();
|
||||
await page.getByTestId("sidebar-search").click();
|
||||
}
|
||||
|
||||
export class DirectoryBootstrapScenario {
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import { openSettings } from "./app";
|
||||
import { clickSettingsBackToWorkspace, openSettingsSection } from "./settings";
|
||||
|
||||
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||
|
||||
/** Persisted nav key -> the testID the app shell renders that item with. */
|
||||
const SHELL_ROW_TEST_IDS = {
|
||||
"new-workspace": "sidebar-global-new-workspace",
|
||||
history: "sidebar-sessions",
|
||||
search: "sidebar-search",
|
||||
schedules: "sidebar-schedules",
|
||||
} as const;
|
||||
|
||||
export type SidebarNavKey = keyof typeof SHELL_ROW_TEST_IDS;
|
||||
|
||||
export interface SidebarNavPreference {
|
||||
key: string;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
function shellRow(page: Page, key: SidebarNavKey): Locator {
|
||||
// `:visible` rather than a plain testID: the shell keeps a compact copy of the
|
||||
// sidebar mounted, so the pinned row is the first visible match.
|
||||
return page.locator(`[data-testid="${SHELL_ROW_TEST_IDS[key]}"]:visible`).first();
|
||||
}
|
||||
|
||||
function settingsRow(page: Page, key: SidebarNavKey): Locator {
|
||||
return page.getByTestId(`sidebar-nav-item-${key}`);
|
||||
}
|
||||
|
||||
function itemLabel(key: SidebarNavKey): string {
|
||||
return {
|
||||
"new-workspace": "New workspace",
|
||||
history: "History",
|
||||
search: "Search",
|
||||
schedules: "Schedules",
|
||||
}[key];
|
||||
}
|
||||
|
||||
async function rowTop(locator: Locator): Promise<number | null> {
|
||||
const box = await locator.boundingBox();
|
||||
return box?.y ?? null;
|
||||
}
|
||||
|
||||
export async function seedSidebarNavPreferences(
|
||||
page: Page,
|
||||
preferences: SidebarNavPreference[],
|
||||
): Promise<void> {
|
||||
await page.addInitScript(
|
||||
({ key, sidebarNavItems }) => {
|
||||
localStorage.setItem(key, JSON.stringify({ sidebarNavItems }));
|
||||
},
|
||||
{ key: APP_SETTINGS_KEY, sidebarNavItems: preferences },
|
||||
);
|
||||
}
|
||||
|
||||
export async function openSidebarNavSettings(page: Page): Promise<void> {
|
||||
await openSettings(page);
|
||||
await openSettingsSection(page, "appearance");
|
||||
await expect(page.getByTestId("sidebar-nav-section")).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function leaveSettings(page: Page): Promise<void> {
|
||||
await clickSettingsBackToWorkspace(page);
|
||||
}
|
||||
|
||||
export async function moveSidebarNavItemUp(page: Page, key: SidebarNavKey): Promise<void> {
|
||||
await settingsRow(page, key).getByRole("button", { name: "Move up", exact: true }).click();
|
||||
}
|
||||
|
||||
export async function setSidebarNavItemVisible(
|
||||
page: Page,
|
||||
key: SidebarNavKey,
|
||||
visible: boolean,
|
||||
): Promise<void> {
|
||||
const toggle = settingsRow(page, key).getByRole("switch", {
|
||||
name: itemLabel(key),
|
||||
exact: true,
|
||||
});
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute("aria-checked", String(visible));
|
||||
}
|
||||
|
||||
export async function expectSidebarNavSettingsRow(
|
||||
page: Page,
|
||||
expected: { key: SidebarNavKey; label: string; visible: boolean },
|
||||
): Promise<void> {
|
||||
const row = settingsRow(page, expected.key);
|
||||
await expect(row).toBeVisible();
|
||||
await expect(row.getByText(expected.label, { exact: true })).toBeVisible();
|
||||
const toggle = row.getByRole("switch", { name: expected.label, exact: true });
|
||||
await expect(toggle).toHaveAccessibleName(expected.label);
|
||||
await expect(toggle).toHaveAttribute("aria-checked", String(expected.visible));
|
||||
}
|
||||
|
||||
export async function expectSidebarNavSettingsOrder(
|
||||
page: Page,
|
||||
keys: SidebarNavKey[],
|
||||
): Promise<void> {
|
||||
await expectVerticalOrder(keys, (key) => settingsRow(page, key), "sidebar nav settings rows");
|
||||
}
|
||||
|
||||
export async function expectSidebarOrder(page: Page, keys: SidebarNavKey[]): Promise<void> {
|
||||
await expectVerticalOrder(keys, (key) => shellRow(page, key), "app shell sidebar rows");
|
||||
}
|
||||
|
||||
export async function expectSidebarItemHidden(page: Page, key: SidebarNavKey): Promise<void> {
|
||||
await expect(page.locator(`[data-testid="${SHELL_ROW_TEST_IDS[key]}"]:visible`)).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function expectStoredSidebarNav(
|
||||
page: Page,
|
||||
expected: SidebarNavPreference[],
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate((key) => {
|
||||
const raw = localStorage.getItem(key);
|
||||
return raw ? (JSON.parse(raw).sidebarNavItems ?? null) : null;
|
||||
}, APP_SETTINGS_KEY),
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
.toEqual(expected);
|
||||
}
|
||||
|
||||
async function expectVerticalOrder(
|
||||
keys: SidebarNavKey[],
|
||||
locate: (key: SidebarNavKey) => Locator,
|
||||
subject: string,
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const measured = await Promise.all(
|
||||
keys.map(async (key) => ({ key, top: await rowTop(locate(key)) })),
|
||||
);
|
||||
const laidOut = measured.filter(
|
||||
(entry): entry is { key: SidebarNavKey; top: number } => entry.top !== null,
|
||||
);
|
||||
if (laidOut.length !== keys.length) return null;
|
||||
return laidOut.sort((a, b) => a.top - b.top).map((entry) => entry.key);
|
||||
},
|
||||
{ message: `Expected ${subject} in order`, timeout: 15_000 },
|
||||
)
|
||||
.toEqual(keys);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { projectPluginTimelineItems } from "@/plugins/timeline/projection";
|
||||
import { findMountedWindowStart } from "./history-window";
|
||||
import { buildAgentStreamRenderModel } from "./model";
|
||||
|
||||
function createTimestamp(seed: number): Date {
|
||||
@@ -74,6 +76,31 @@ describe("buildAgentStreamRenderModel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the mounted boundary stable when a transformer filters an earlier item", () => {
|
||||
const tail = [
|
||||
userMessage("filtered", 1),
|
||||
assistantMessage("hidden", 2),
|
||||
userMessage("visible-u", 3),
|
||||
assistantMessage("visible-a", 4),
|
||||
];
|
||||
|
||||
const projectedTail = projectPluginTimelineItems(tail, ({ sourceId }) =>
|
||||
sourceId === "filtered" ? [] : undefined,
|
||||
);
|
||||
const historyStart = findMountedWindowStart({ items: projectedTail, minMountedCount: 2 });
|
||||
const model = buildAgentStreamRenderModel({
|
||||
isTurnActive: false,
|
||||
activeTurnStartedAt: null,
|
||||
tail: projectedTail,
|
||||
head: [],
|
||||
platform: "native",
|
||||
isMobileBreakpoint: false,
|
||||
historyStart,
|
||||
});
|
||||
|
||||
expect(model.history.map((item) => item.id)).toEqual(["visible-a", "visible-u"]);
|
||||
});
|
||||
|
||||
it("keeps head separate from committed history on desktop web", () => {
|
||||
const tail: StreamItem[] = [];
|
||||
for (let index = 0; index < 60; index += 1) {
|
||||
|
||||
@@ -107,7 +107,8 @@ import type { Theme } from "@/styles/theme";
|
||||
import { recordRenderProfileReasons } from "@/utils/render-profiler";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { useStreamHistoryWindow } from "./use-stream-history-window";
|
||||
import { PluginTimelineItemView } from "@/plugins/timeline";
|
||||
import { PluginTimelineItemView, useInstalledTimelineTransform } from "@/plugins/timeline";
|
||||
import { projectPluginTimelineItems } from "@/plugins/timeline/projection";
|
||||
|
||||
function renderLiveAuxiliaryNode(input: {
|
||||
pendingPermissions: ReactNode;
|
||||
@@ -375,6 +376,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
|
||||
// Get serverId (fallback to agent's serverId if not provided)
|
||||
const resolvedServerId = serverId ?? context.serverId ?? "";
|
||||
const transformTimelineItem = useInstalledTimelineTransform(resolvedServerId);
|
||||
|
||||
const client = useSessionStore((state) => state.sessions[resolvedServerId]?.client ?? null);
|
||||
const sessionStreamHead = useSessionStore((state) =>
|
||||
@@ -557,6 +559,13 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
toolCallDetailLevel,
|
||||
],
|
||||
);
|
||||
const projectedPlugins = useMemo(
|
||||
() => ({
|
||||
tail: projectPluginTimelineItems(projectedToolCalls.tail, transformTimelineItem),
|
||||
head: projectPluginTimelineItems(projectedToolCalls.head, transformTimelineItem),
|
||||
}),
|
||||
[projectedToolCalls.head, projectedToolCalls.tail, transformTimelineItem],
|
||||
);
|
||||
const {
|
||||
start: historyWindowStart,
|
||||
hasLocalHistory,
|
||||
@@ -564,7 +573,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
loadOlder,
|
||||
} = useStreamHistoryWindow({
|
||||
agentId,
|
||||
items: projectedToolCalls.tail,
|
||||
items: projectedPlugins.tail,
|
||||
loadRemoteOlder,
|
||||
});
|
||||
const isLoadingOlder = remoteIsLoadingOlder;
|
||||
@@ -575,8 +584,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
return buildAgentStreamRenderModel({
|
||||
isTurnActive,
|
||||
activeTurnStartedAt: effectiveTurnPresentation.startedAt,
|
||||
tail: projectedToolCalls.tail,
|
||||
head: projectedToolCalls.head,
|
||||
tail: projectedPlugins.tail,
|
||||
head: projectedPlugins.head,
|
||||
platform: isWeb ? "web" : "native",
|
||||
isMobileBreakpoint: isMobile,
|
||||
historyStart: historyWindowStart,
|
||||
@@ -584,8 +593,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
}, [
|
||||
isMobile,
|
||||
isTurnActive,
|
||||
projectedToolCalls.head,
|
||||
projectedToolCalls.tail,
|
||||
projectedPlugins.head,
|
||||
projectedPlugins.tail,
|
||||
effectiveTurnPresentation.startedAt,
|
||||
historyWindowStart,
|
||||
]);
|
||||
|
||||
@@ -462,7 +462,6 @@ function ChangedFilesPane({
|
||||
workspaceId={workspaceId}
|
||||
cwd={workspaceRoot}
|
||||
enabled={isOpen}
|
||||
modeScope="compact-explorer"
|
||||
onOpenFile={onOpenFile}
|
||||
onAddToChat={canAddToChat ? addFile : undefined}
|
||||
state={changesState}
|
||||
|
||||
@@ -1,16 +1,5 @@
|
||||
import { router, usePathname } from "expo-router";
|
||||
import {
|
||||
CalendarClock,
|
||||
FolderPlus,
|
||||
GitBranch,
|
||||
History,
|
||||
Home,
|
||||
Plus,
|
||||
Search,
|
||||
Server,
|
||||
Settings,
|
||||
X,
|
||||
} from "lucide-react-native";
|
||||
import { router } from "expo-router";
|
||||
import { FolderPlus, GitBranch, Home, Server, Settings, X } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
||||
import {
|
||||
@@ -33,8 +22,8 @@ import {
|
||||
SIDEBAR_RESIZE_FAIL_OFFSET,
|
||||
} from "@/components/sidebar-resize-handle-layout";
|
||||
import { HostPicker } from "@/components/hosts/host-picker";
|
||||
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
|
||||
import { SidebarDisplayPreferencesMenu } from "@/components/sidebar/display-preferences/menu";
|
||||
import { SidebarNavRows } from "@/components/sidebar/sidebar-nav-rows";
|
||||
import { SidebarHelpMenu } from "@/components/sidebar/sidebar-help-menu";
|
||||
import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
@@ -42,8 +31,6 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
|
||||
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useOpenAddProject } from "@/hooks/use-open-add-project";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import { canCreateWorktreeForProjectKind } from "@/projects/host-projects";
|
||||
import { useHostFeature } from "@/runtime/host-features";
|
||||
import {
|
||||
type SidebarProjectEntry,
|
||||
type SidebarWorkspaceEntry,
|
||||
@@ -54,28 +41,20 @@ import { RetainedPanelActivity } from "@/components/retained-panel";
|
||||
import type { SidebarWorkspaceGroup } from "@/components/sidebar/sidebar-labels";
|
||||
import type { SidebarProjectIconTarget } from "@/utils/sidebar-project-row-model";
|
||||
import { type SidebarGroupMode, useSidebarViewStore } from "@/stores/sidebar-view-store";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import { useWorkspace } from "@/stores/session-store-hooks";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window";
|
||||
import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
|
||||
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
|
||||
import {
|
||||
buildOpenProjectRoute,
|
||||
buildNewWorkspaceRoute,
|
||||
buildSchedulesRoute,
|
||||
buildSessionsRoute,
|
||||
buildSettingsAddHostRoute,
|
||||
buildSettingsRoute,
|
||||
} from "@/utils/host-routes";
|
||||
import { openHostOverview } from "@/navigation/settings-navigation";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
|
||||
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
|
||||
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
|
||||
import { PluginSidebarItems } from "@/plugins";
|
||||
|
||||
type SidebarTheme = ReturnType<typeof useUnistyles>["theme"];
|
||||
|
||||
@@ -102,20 +81,16 @@ interface SidebarSharedProps {
|
||||
handleHome: () => void;
|
||||
handleSettings: () => void;
|
||||
labels: SidebarLabels;
|
||||
newWorkspaceKeys: ShortcutKey[][] | null;
|
||||
handleAddHost: () => void;
|
||||
handleOpenHostSettings: (serverId: string) => void;
|
||||
}
|
||||
|
||||
interface SidebarLabels {
|
||||
addProject: string;
|
||||
newWorkspace: string;
|
||||
hosts: string;
|
||||
home: string;
|
||||
settings: string;
|
||||
searchHosts: string;
|
||||
sessions: string;
|
||||
schedules: string;
|
||||
closeSidebar: string;
|
||||
}
|
||||
|
||||
@@ -124,15 +99,11 @@ interface MobileSidebarProps extends SidebarSharedProps {
|
||||
insetsTop: number;
|
||||
insetsBottom: number;
|
||||
closeSidebar: () => void;
|
||||
handleViewMoreNavigate: () => void;
|
||||
handleViewSchedulesNavigate: () => void;
|
||||
}
|
||||
|
||||
interface DesktopSidebarProps extends SidebarSharedProps {
|
||||
insetsTop: number;
|
||||
active: boolean;
|
||||
handleViewMore: () => void;
|
||||
handleViewSchedules: () => void;
|
||||
}
|
||||
|
||||
export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boolean }) {
|
||||
@@ -223,25 +194,13 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole
|
||||
router.push(buildOpenProjectRoute());
|
||||
}, []);
|
||||
|
||||
const handleViewMoreNavigate = useCallback(() => {
|
||||
router.push(buildSessionsRoute());
|
||||
}, []);
|
||||
|
||||
const handleViewSchedulesNavigate = useCallback(() => {
|
||||
router.push(buildSchedulesRoute());
|
||||
}, []);
|
||||
|
||||
const newWorkspaceKeys = useShortcutKeys("new-workspace");
|
||||
const labels = useMemo(
|
||||
(): SidebarLabels => ({
|
||||
addProject: t("sidebar.actions.addProject"),
|
||||
newWorkspace: t("sidebar.actions.newWorkspace"),
|
||||
hosts: t("sidebar.actions.hosts"),
|
||||
home: t("sidebar.actions.home"),
|
||||
settings: t("sidebar.actions.settings"),
|
||||
searchHosts: t("sidebar.host.searchPlaceholder"),
|
||||
sessions: t("sidebar.sections.sessions"),
|
||||
schedules: t("sidebar.sections.schedules"),
|
||||
closeSidebar: t("sidebar.actions.closeSidebar"),
|
||||
}),
|
||||
[t],
|
||||
@@ -265,7 +224,6 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole
|
||||
toggleProjectCollapsed,
|
||||
handleRefresh,
|
||||
labels,
|
||||
newWorkspaceKeys,
|
||||
};
|
||||
|
||||
if (isCompactLayout) {
|
||||
@@ -282,8 +240,6 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole
|
||||
handleSettings={handleSettingsMobile}
|
||||
handleAddHost={handleAddHostMobile}
|
||||
handleOpenHostSettings={handleOpenHostSettingsMobile}
|
||||
handleViewMoreNavigate={handleViewMoreNavigate}
|
||||
handleViewSchedulesNavigate={handleViewSchedulesNavigate}
|
||||
/>
|
||||
</RetainedPanelActivity>
|
||||
);
|
||||
@@ -300,8 +256,6 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole
|
||||
handleSettings={handleSettingsDesktop}
|
||||
handleAddHost={handleAddHostDesktop}
|
||||
handleOpenHostSettings={handleOpenHostSettingsDesktop}
|
||||
handleViewMore={handleViewMoreNavigate}
|
||||
handleViewSchedules={handleViewSchedulesNavigate}
|
||||
/>
|
||||
</RetainedPanelActivity>
|
||||
);
|
||||
@@ -487,61 +441,6 @@ function IconTooltipContent({
|
||||
);
|
||||
}
|
||||
|
||||
const SidebarNewWorkspaceHeaderRow = memo(function SidebarNewWorkspaceHeaderRow({
|
||||
label,
|
||||
testID,
|
||||
variant,
|
||||
shortcutKeys,
|
||||
onBeforeNavigate,
|
||||
}: {
|
||||
label: string;
|
||||
testID: string;
|
||||
variant: "header" | "compact";
|
||||
shortcutKeys: ShortcutKey[][] | null;
|
||||
onBeforeNavigate?: () => void;
|
||||
}) {
|
||||
const activeWorkspaceSelection = useActiveWorkspaceSelection();
|
||||
const activeWorkspaceServerId = activeWorkspaceSelection?.serverId ?? null;
|
||||
const activeWorkspaceId = activeWorkspaceSelection?.workspaceId ?? null;
|
||||
const activeWorkspace = useWorkspace(activeWorkspaceServerId, activeWorkspaceId);
|
||||
const supportsWorkspaceMultiplicity = useHostFeature(
|
||||
activeWorkspaceServerId,
|
||||
"workspaceMultiplicity",
|
||||
);
|
||||
const canUseActiveWorkspaceContext = Boolean(
|
||||
activeWorkspace &&
|
||||
(supportsWorkspaceMultiplicity || canCreateWorktreeForProjectKind(activeWorkspace.projectKind)),
|
||||
);
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
onBeforeNavigate?.();
|
||||
router.push(
|
||||
activeWorkspaceServerId
|
||||
? buildNewWorkspaceRoute(
|
||||
activeWorkspace && canUseActiveWorkspaceContext
|
||||
? {
|
||||
serverId: activeWorkspaceServerId,
|
||||
sourceDirectory: activeWorkspace.projectRootPath,
|
||||
projectId: activeWorkspace.projectId,
|
||||
}
|
||||
: { serverId: activeWorkspaceServerId },
|
||||
)
|
||||
: buildNewWorkspaceRoute(),
|
||||
);
|
||||
}, [activeWorkspace, activeWorkspaceServerId, canUseActiveWorkspaceContext, onBeforeNavigate]);
|
||||
|
||||
return (
|
||||
<SidebarHeaderRow
|
||||
icon={Plus}
|
||||
label={label}
|
||||
onPress={handlePress}
|
||||
testID={testID}
|
||||
variant={variant}
|
||||
shortcutKeys={shortcutKeys}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
function SidebarFooter({
|
||||
theme,
|
||||
handleOpenProject,
|
||||
@@ -622,7 +521,6 @@ function MobileSidebar({
|
||||
shortcutIndexByWorkspaceKey,
|
||||
toggleProjectCollapsed,
|
||||
handleRefresh,
|
||||
newWorkspaceKeys,
|
||||
handleOpenProject,
|
||||
handleHome,
|
||||
handleSettings,
|
||||
@@ -632,25 +530,10 @@ function MobileSidebar({
|
||||
insetsTop,
|
||||
insetsBottom,
|
||||
closeSidebar,
|
||||
handleViewMoreNavigate,
|
||||
handleViewSchedulesNavigate,
|
||||
}: MobileSidebarProps) {
|
||||
const pathname = usePathname();
|
||||
const hasActiveHostFilter = useSidebarViewStore((state) => state.hostFilters.length > 0);
|
||||
const isSessionsActive = pathname.includes("/sessions");
|
||||
const isSchedulesActive = pathname.includes("/schedules");
|
||||
const { gesture: closeGesture, gestureRef: closeGestureRef } = useCloseAgentListGesture();
|
||||
|
||||
const handleViewMore = useCallback(() => {
|
||||
closeSidebar();
|
||||
handleViewMoreNavigate();
|
||||
}, [closeSidebar, handleViewMoreNavigate]);
|
||||
|
||||
const handleViewSchedules = useCallback(() => {
|
||||
closeSidebar();
|
||||
handleViewSchedulesNavigate();
|
||||
}, [closeSidebar, handleViewSchedulesNavigate]);
|
||||
|
||||
const handleWorkspacePress = useCallback(() => {
|
||||
closeSidebar();
|
||||
}, [closeSidebar]);
|
||||
@@ -672,32 +555,7 @@ function MobileSidebar({
|
||||
>
|
||||
<View style={styles.sidebarContent} pointerEvents="auto">
|
||||
<WindowChromeSafeArea placement="below" />
|
||||
<View style={styles.sidebarHeaderGroup}>
|
||||
<SidebarNewWorkspaceHeaderRow
|
||||
label={labels.newWorkspace}
|
||||
testID="sidebar-global-new-workspace"
|
||||
variant="compact"
|
||||
shortcutKeys={newWorkspaceKeys}
|
||||
onBeforeNavigate={closeSidebar}
|
||||
/>
|
||||
<SidebarHeaderRow
|
||||
icon={History}
|
||||
label={labels.sessions}
|
||||
onPress={handleViewMore}
|
||||
isActive={isSessionsActive}
|
||||
testID="sidebar-sessions"
|
||||
variant="compact"
|
||||
/>
|
||||
<SidebarHeaderRow
|
||||
icon={CalendarClock}
|
||||
label={labels.schedules}
|
||||
onPress={handleViewSchedules}
|
||||
isActive={isSchedulesActive}
|
||||
testID="sidebar-schedules"
|
||||
variant="compact"
|
||||
/>
|
||||
<PluginSidebarItems onBeforeNavigate={closeSidebar} />
|
||||
</View>
|
||||
<SidebarNavRows style={styles.sidebarHeaderGroup} onBeforeNavigate={closeSidebar} />
|
||||
<WindowChromeSafeArea placement="inline" style={styles.mobileCloseButtonRow}>
|
||||
<Pressable
|
||||
style={styles.mobileCloseButton}
|
||||
@@ -774,7 +632,6 @@ function DesktopSidebar({
|
||||
shortcutIndexByWorkspaceKey,
|
||||
toggleProjectCollapsed,
|
||||
handleRefresh,
|
||||
newWorkspaceKeys,
|
||||
handleOpenProject,
|
||||
handleHome,
|
||||
handleSettings,
|
||||
@@ -783,14 +640,9 @@ function DesktopSidebar({
|
||||
handleOpenHostSettings,
|
||||
insetsTop,
|
||||
active,
|
||||
handleViewMore,
|
||||
handleViewSchedules,
|
||||
}: DesktopSidebarProps) {
|
||||
const ownsTopLeft = useOwnsWindowChromeCorner("top-left");
|
||||
const pathname = usePathname();
|
||||
const hasActiveHostFilter = useSidebarViewStore((state) => state.hostFilters.length > 0);
|
||||
const isSessionsActive = pathname.includes("/sessions");
|
||||
const isSchedulesActive = pathname.includes("/schedules");
|
||||
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
|
||||
const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth);
|
||||
const { width: viewportWidth } = useWindowDimensions();
|
||||
@@ -898,31 +750,7 @@ function DesktopSidebar({
|
||||
) : (
|
||||
<TitlebarDragRegion />
|
||||
)}
|
||||
<View style={sidebarHeaderGroupStyle}>
|
||||
<SidebarNewWorkspaceHeaderRow
|
||||
label={labels.newWorkspace}
|
||||
testID="sidebar-global-new-workspace"
|
||||
variant="compact"
|
||||
shortcutKeys={newWorkspaceKeys}
|
||||
/>
|
||||
<SidebarHeaderRow
|
||||
icon={History}
|
||||
label={labels.sessions}
|
||||
onPress={handleViewMore}
|
||||
isActive={isSessionsActive}
|
||||
testID="sidebar-sessions"
|
||||
variant="compact"
|
||||
/>
|
||||
<SidebarHeaderRow
|
||||
icon={CalendarClock}
|
||||
label={labels.schedules}
|
||||
onPress={handleViewSchedules}
|
||||
isActive={isSchedulesActive}
|
||||
testID="sidebar-schedules"
|
||||
variant="compact"
|
||||
/>
|
||||
<PluginSidebarItems />
|
||||
</View>
|
||||
<SidebarNavRows style={sidebarHeaderGroupStyle} />
|
||||
</View>
|
||||
|
||||
{isInitialLoad && !hasActiveHostFilter ? (
|
||||
@@ -971,45 +799,10 @@ function DesktopSidebar({
|
||||
}
|
||||
|
||||
function WorkspacesSectionHeader() {
|
||||
const { theme } = useUnistyles();
|
||||
const setCommandCenterOpen = useKeyboardShortcutsStore((state) => state.setCommandCenterOpen);
|
||||
const commandCenterKeys = useShortcutKeys("toggle-command-center");
|
||||
const handleSearchPress = useCallback(() => setCommandCenterOpen(true), [setCommandCenterOpen]);
|
||||
const searchButtonStyle = useCallback(
|
||||
({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.workspacesHeaderIconButton,
|
||||
(hovered || pressed) && styles.workspacesHeaderIconButtonHovered,
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.workspacesSectionHeader}>
|
||||
<Text style={styles.workspacesSectionTitle}>Workspaces</Text>
|
||||
<View style={styles.workspacesSectionActions}>
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Open command center"
|
||||
testID="sidebar-command-center-search"
|
||||
style={searchButtonStyle}
|
||||
onPress={handleSearchPress}
|
||||
>
|
||||
{({ hovered, pressed }) => (
|
||||
<Search
|
||||
size={14}
|
||||
color={
|
||||
hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="center" offset={8}>
|
||||
<IconTooltipContent label="Search" shortcutKeys={commandCenterKeys} />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<View>
|
||||
@@ -1075,16 +868,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
workspacesHeaderIconButton: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: theme.borderRadius.md,
|
||||
},
|
||||
workspacesHeaderIconButtonHovered: {
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
},
|
||||
sidebarContent: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { router, usePathname } from "expo-router";
|
||||
import { CalendarClock, History, Plus, Search } from "lucide-react-native";
|
||||
import { memo, useCallback, useMemo, type ComponentType } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import { PluginSidebarItemRow } from "@/plugins/sidebar-items";
|
||||
import { canCreateWorktreeForProjectKind } from "@/projects/host-projects";
|
||||
import { useHostFeature } from "@/runtime/host-features";
|
||||
import {
|
||||
builtinSidebarNavLabelKey,
|
||||
builtinSidebarNavShortcutAction,
|
||||
type BuiltinSidebarNavId,
|
||||
} from "@/sidebar-nav/model";
|
||||
import { useSidebarNavItems } from "@/sidebar-nav/use-sidebar-nav-items";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import { useWorkspace } from "@/stores/session-store-hooks";
|
||||
import {
|
||||
buildNewWorkspaceRoute,
|
||||
buildSchedulesRoute,
|
||||
buildSessionsRoute,
|
||||
} from "@/utils/host-routes";
|
||||
|
||||
interface SidebarNavRowProps {
|
||||
onBeforeNavigate?: () => void;
|
||||
}
|
||||
|
||||
interface SidebarNavRowsProps extends SidebarNavRowProps {
|
||||
/** Style for the group wrapper, which the sidebar owns. */
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Top-level sidebar navigation, ordered and filtered by the user's
|
||||
* `sidebarNavItems` preference. Renders nothing — not even the bordered group
|
||||
* wrapper — when every item is hidden.
|
||||
*/
|
||||
export function SidebarNavRows({ style, onBeforeNavigate }: SidebarNavRowsProps) {
|
||||
const { items } = useSidebarNavItems();
|
||||
const visibleItems = useMemo(() => items.filter((item) => item.visible), [items]);
|
||||
|
||||
if (visibleItems.length === 0) return null;
|
||||
|
||||
return (
|
||||
<View style={style}>
|
||||
{visibleItems.map((item) => {
|
||||
if (item.kind === "plugin") {
|
||||
return (
|
||||
<PluginSidebarItemRow
|
||||
key={item.key}
|
||||
group={item.group}
|
||||
onBeforeNavigate={onBeforeNavigate}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const Row = BUILTIN_ROWS[item.id];
|
||||
return <Row key={item.key} onBeforeNavigate={onBeforeNavigate} />;
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const SidebarNewWorkspaceRow = memo(function SidebarNewWorkspaceRow({
|
||||
onBeforeNavigate,
|
||||
}: SidebarNavRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const shortcutKeys = useShortcutKeys(builtinSidebarNavShortcutAction("new-workspace"));
|
||||
const activeWorkspaceSelection = useActiveWorkspaceSelection();
|
||||
const activeWorkspaceServerId = activeWorkspaceSelection?.serverId ?? null;
|
||||
const activeWorkspaceId = activeWorkspaceSelection?.workspaceId ?? null;
|
||||
const activeWorkspace = useWorkspace(activeWorkspaceServerId, activeWorkspaceId);
|
||||
const supportsWorkspaceMultiplicity = useHostFeature(
|
||||
activeWorkspaceServerId,
|
||||
"workspaceMultiplicity",
|
||||
);
|
||||
const canUseActiveWorkspaceContext = Boolean(
|
||||
activeWorkspace &&
|
||||
(supportsWorkspaceMultiplicity || canCreateWorktreeForProjectKind(activeWorkspace.projectKind)),
|
||||
);
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
onBeforeNavigate?.();
|
||||
router.push(
|
||||
activeWorkspaceServerId
|
||||
? buildNewWorkspaceRoute(
|
||||
activeWorkspace && canUseActiveWorkspaceContext
|
||||
? {
|
||||
serverId: activeWorkspaceServerId,
|
||||
sourceDirectory: activeWorkspace.projectRootPath,
|
||||
projectId: activeWorkspace.projectId,
|
||||
}
|
||||
: { serverId: activeWorkspaceServerId },
|
||||
)
|
||||
: buildNewWorkspaceRoute(),
|
||||
);
|
||||
}, [activeWorkspace, activeWorkspaceServerId, canUseActiveWorkspaceContext, onBeforeNavigate]);
|
||||
|
||||
return (
|
||||
<SidebarHeaderRow
|
||||
icon={Plus}
|
||||
label={t(builtinSidebarNavLabelKey("new-workspace"))}
|
||||
onPress={handlePress}
|
||||
testID="sidebar-global-new-workspace"
|
||||
variant="compact"
|
||||
shortcutKeys={shortcutKeys}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
function SidebarHistoryRow({ onBeforeNavigate }: SidebarNavRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const pathname = usePathname();
|
||||
const handlePress = useCallback(() => {
|
||||
onBeforeNavigate?.();
|
||||
router.push(buildSessionsRoute());
|
||||
}, [onBeforeNavigate]);
|
||||
|
||||
return (
|
||||
<SidebarHeaderRow
|
||||
icon={History}
|
||||
label={t(builtinSidebarNavLabelKey("history"))}
|
||||
onPress={handlePress}
|
||||
isActive={pathname.includes("/sessions")}
|
||||
testID="sidebar-sessions"
|
||||
variant="compact"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarSearchRow({ onBeforeNavigate }: SidebarNavRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const shortcutKeys = useShortcutKeys(builtinSidebarNavShortcutAction("search"));
|
||||
const setCommandCenterOpen = useKeyboardShortcutsStore((state) => state.setCommandCenterOpen);
|
||||
const handlePress = useCallback(() => {
|
||||
onBeforeNavigate?.();
|
||||
setCommandCenterOpen(true);
|
||||
}, [onBeforeNavigate, setCommandCenterOpen]);
|
||||
|
||||
return (
|
||||
<SidebarHeaderRow
|
||||
icon={Search}
|
||||
label={t(builtinSidebarNavLabelKey("search"))}
|
||||
onPress={handlePress}
|
||||
testID="sidebar-search"
|
||||
variant="compact"
|
||||
shortcutKeys={shortcutKeys}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarSchedulesRow({ onBeforeNavigate }: SidebarNavRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const pathname = usePathname();
|
||||
const handlePress = useCallback(() => {
|
||||
onBeforeNavigate?.();
|
||||
router.push(buildSchedulesRoute());
|
||||
}, [onBeforeNavigate]);
|
||||
|
||||
return (
|
||||
<SidebarHeaderRow
|
||||
icon={CalendarClock}
|
||||
label={t(builtinSidebarNavLabelKey("schedules"))}
|
||||
onPress={handlePress}
|
||||
isActive={pathname.includes("/schedules")}
|
||||
testID="sidebar-schedules"
|
||||
variant="compact"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const BUILTIN_ROWS: Record<BuiltinSidebarNavId, ComponentType<SidebarNavRowProps>> = {
|
||||
"new-workspace": SidebarNewWorkspaceRow,
|
||||
history: SidebarHistoryRow,
|
||||
search: SidebarSearchRow,
|
||||
schedules: SidebarSchedulesRow,
|
||||
};
|
||||
@@ -83,6 +83,11 @@ import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import { AutocompletePopover } from "@/components/ui/autocomplete-popover";
|
||||
import type { AutocompleteOption } from "@/components/ui/autocomplete";
|
||||
import { useAgentAutocomplete } from "@/hooks/use-agent-autocomplete";
|
||||
import { usePluginClientSlashCommands } from "@/plugins/client-slash-commands";
|
||||
import {
|
||||
executePluginClientSlashCommand,
|
||||
resolvePluginClientSlashCommand,
|
||||
} from "@/plugins/client-slash-commands/model";
|
||||
import {
|
||||
useHostRuntimeAgentDirectoryStatus,
|
||||
useHostRuntimeClient,
|
||||
@@ -1268,6 +1273,11 @@ function ComposerContentImpl({
|
||||
onChangeAttachments: setSelectedAttachments,
|
||||
anchorRef: attachButtonRef,
|
||||
});
|
||||
const pluginClientSlashCommands = usePluginClientSlashCommands({
|
||||
serverId,
|
||||
workspaceId,
|
||||
agentId,
|
||||
});
|
||||
const isComposerLocked = resolveIsComposerLocked(submitBehavior, isSubmitLoading);
|
||||
const keyboardHandlerIdRef = useRef(
|
||||
`message-input:${serverId}:${agentId}:${Math.random().toString(36).slice(2)}`,
|
||||
@@ -1319,6 +1329,27 @@ function ComposerContentImpl({
|
||||
],
|
||||
);
|
||||
|
||||
const runPluginClientSlashCommand = useCallback(
|
||||
(resolved: { command: (typeof pluginClientSlashCommands)[number]; args: string }): boolean => {
|
||||
if (blurOnSubmit) messageInputRef.current?.blur();
|
||||
clearDraft("sent");
|
||||
replaceUserInput("");
|
||||
setSelectedAttachments([]);
|
||||
resetSuppression();
|
||||
setSendError(null);
|
||||
executePluginClientSlashCommand({
|
||||
command: resolved.command,
|
||||
args: resolved.args,
|
||||
onError(error) {
|
||||
console.error("[Composer] Failed to run plugin client slash command:", error);
|
||||
toastErrorRef.current(error instanceof Error ? error.message : String(error));
|
||||
},
|
||||
});
|
||||
return true;
|
||||
},
|
||||
[blurOnSubmit, clearDraft, replaceUserInput, resetSuppression, setSelectedAttachments],
|
||||
);
|
||||
|
||||
const autocomplete = useAgentAutocomplete({
|
||||
userInput,
|
||||
cursorIndex,
|
||||
@@ -1328,6 +1359,7 @@ function ComposerContentImpl({
|
||||
draftConfig: commandDraftConfig,
|
||||
canExecuteClientSlashCommand: buildOutgoingAttachments(attachments).length === 0,
|
||||
onClientSlashCommand: runClientSlashCommand,
|
||||
pluginClientSlashCommands,
|
||||
onAutocompleteApplied: () => {
|
||||
messageInputRef.current?.focus();
|
||||
},
|
||||
@@ -1595,6 +1627,12 @@ function ComposerContentImpl({
|
||||
if (clientSlashCommand && runClientSlashCommand(clientSlashCommand)) {
|
||||
return;
|
||||
}
|
||||
const pluginSlashCommand = resolvePluginClientSlashCommand({
|
||||
text: payload.text,
|
||||
hasAttachments: outgoingAttachments.length > 0,
|
||||
commands: pluginClientSlashCommands,
|
||||
});
|
||||
if (pluginSlashCommand && runPluginClientSlashCommand(pluginSlashCommand)) return;
|
||||
|
||||
if (blurOnSubmit) {
|
||||
messageInputRef.current?.blur();
|
||||
@@ -1606,6 +1644,8 @@ function ComposerContentImpl({
|
||||
blurOnSubmit,
|
||||
buildOutgoingAttachments,
|
||||
runClientSlashCommand,
|
||||
pluginClientSlashCommands,
|
||||
runPluginClientSlashCommand,
|
||||
sendMessageWithContent,
|
||||
],
|
||||
);
|
||||
@@ -1860,9 +1900,22 @@ function ComposerContentImpl({
|
||||
if (clientSlashCommand && runClientSlashCommand(clientSlashCommand)) {
|
||||
return;
|
||||
}
|
||||
const pluginSlashCommand = resolvePluginClientSlashCommand({
|
||||
text: payload.text,
|
||||
hasAttachments: outgoingAttachments.length > 0,
|
||||
commands: pluginClientSlashCommands,
|
||||
});
|
||||
if (pluginSlashCommand && runPluginClientSlashCommand(pluginSlashCommand)) return;
|
||||
queueMessage(payload.text, outgoingAttachments);
|
||||
},
|
||||
[attachments, buildOutgoingAttachments, queueMessage, runClientSlashCommand],
|
||||
[
|
||||
attachments,
|
||||
buildOutgoingAttachments,
|
||||
pluginClientSlashCommands,
|
||||
queueMessage,
|
||||
runClientSlashCommand,
|
||||
runPluginClientSlashCommand,
|
||||
],
|
||||
);
|
||||
|
||||
const hasSendableContent = userInput.trim().length > 0 || selectedAttachments.length > 0;
|
||||
|
||||
@@ -15,6 +15,7 @@ import { deriveAgentStreamTurnLiveness } from "@/timeline/session-stream-reducer
|
||||
import { planTimelineTailFetch } from "@/timeline/timeline-sync-plan";
|
||||
import { requestTimelineReplacement } from "@/timeline/timeline-replacement";
|
||||
import {
|
||||
consumeForcedTimelineTailReplacement,
|
||||
type TimelineDeliveryMode,
|
||||
type TimelineResponsePayload,
|
||||
type ViewedTimelineOwner,
|
||||
@@ -59,15 +60,6 @@ import { applyCheckoutStatusUpdateFromEvent } from "@/git/checkout-status-cache"
|
||||
import { useProviderSubagentStore } from "@/subagents/provider-store";
|
||||
import { revalidateSessionAfterResume } from "@/contexts/session-resume-revalidation";
|
||||
|
||||
function consumeForcedTimelineTailReplacement(
|
||||
payload: TimelineResponsePayload,
|
||||
replacements: Set<string>,
|
||||
): TimelineResponsePayload {
|
||||
if (payload.direction !== "tail") return payload;
|
||||
if (!replacements.delete(payload.agentId)) return payload;
|
||||
return { ...payload, reset: true };
|
||||
}
|
||||
|
||||
// Re-export types from session-store and draft-store for backward compatibility
|
||||
export type { DraftInput } from "@/stores/draft-store";
|
||||
export type {
|
||||
|
||||
@@ -11,7 +11,11 @@ import {
|
||||
prPanePipelineQueryKey,
|
||||
prPaneTimelineQueryKey,
|
||||
} from "@/git/pull-request-panel/query-keys";
|
||||
import { resetReviewDraftStore, useReviewDraftStore } from "@/review/store";
|
||||
import {
|
||||
resetWorkingDiffComparisons,
|
||||
resolveWorkingDiffComparison,
|
||||
selectWorkingDiffComparison,
|
||||
} from "@/git/working-diff-comparison";
|
||||
import {
|
||||
applyCheckoutStatusUpdateFromEvent,
|
||||
ensureCheckoutStatus,
|
||||
@@ -87,10 +91,12 @@ function checkoutStatusUpdate(
|
||||
};
|
||||
}
|
||||
|
||||
function setDiffModeOverride(isDirtyAtSelection: boolean): void {
|
||||
useReviewDraftStore.getState().setDiffModeOverride({
|
||||
scopeKey: "review:scope",
|
||||
override: { serverId, cwd, mode: "base", isDirtyAtSelection },
|
||||
function selectBaseComparison(isDirtyAtSelection: boolean): void {
|
||||
selectWorkingDiffComparison({
|
||||
serverId,
|
||||
cwd,
|
||||
comparison: "base",
|
||||
isDirty: isDirtyAtSelection,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -99,7 +105,7 @@ function createQueryClient(): QueryClient {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetReviewDraftStore();
|
||||
resetWorkingDiffComparisons();
|
||||
});
|
||||
|
||||
describe("fetchCheckoutStatus", () => {
|
||||
@@ -113,13 +119,14 @@ describe("fetchCheckoutStatus", () => {
|
||||
expect(client.getCheckoutStatus).toHaveBeenCalledExactlyOnceWith(cwd);
|
||||
});
|
||||
|
||||
it("expires a manual diff-mode override when the fetched dirty state flipped", async () => {
|
||||
setDiffModeOverride(true);
|
||||
it("expires a manual working-diff comparison when the fetched dirty state flipped", async () => {
|
||||
selectBaseComparison(true);
|
||||
const client = { getCheckoutStatus: vi.fn(async () => checkoutStatus({ isDirty: false })) };
|
||||
|
||||
await fetchCheckoutStatus({ client, serverId, cwd });
|
||||
|
||||
expect(useReviewDraftStore.getState().diffModeOverrides["review:scope"]).toBeUndefined();
|
||||
expect(resolveWorkingDiffComparison({ serverId, cwd, isDirty: false })).toBe("base");
|
||||
expect(resolveWorkingDiffComparison({ serverId, cwd, isDirty: true })).toBe("uncommitted");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -233,9 +240,9 @@ describe("applyCheckoutStatusUpdateFromEvent", () => {
|
||||
).toBe("unauthenticated");
|
||||
});
|
||||
|
||||
it("expires a manual diff-mode override when the pushed dirty state flipped", () => {
|
||||
it("expires a manual working-diff comparison when the pushed dirty state flipped", () => {
|
||||
const queryClient = createQueryClient();
|
||||
setDiffModeOverride(false);
|
||||
selectBaseComparison(false);
|
||||
|
||||
applyCheckoutStatusUpdateFromEvent({
|
||||
queryClient,
|
||||
@@ -243,12 +250,12 @@ describe("applyCheckoutStatusUpdateFromEvent", () => {
|
||||
message: checkoutStatusUpdate(checkoutStatus({ isDirty: true })),
|
||||
});
|
||||
|
||||
expect(useReviewDraftStore.getState().diffModeOverrides["review:scope"]).toBeUndefined();
|
||||
expect(resolveWorkingDiffComparison({ serverId, cwd, isDirty: true })).toBe("uncommitted");
|
||||
});
|
||||
|
||||
it("keeps a manual diff-mode override while the pushed dirty state still matches", () => {
|
||||
it("keeps a manual working-diff comparison while the pushed dirty state still matches", () => {
|
||||
const queryClient = createQueryClient();
|
||||
setDiffModeOverride(true);
|
||||
selectBaseComparison(true);
|
||||
|
||||
applyCheckoutStatusUpdateFromEvent({
|
||||
queryClient,
|
||||
@@ -256,7 +263,7 @@ describe("applyCheckoutStatusUpdateFromEvent", () => {
|
||||
message: checkoutStatusUpdate(checkoutStatus({ isDirty: true })),
|
||||
});
|
||||
|
||||
expect(useReviewDraftStore.getState().diffModeOverrides["review:scope"]).toBeDefined();
|
||||
expect(resolveWorkingDiffComparison({ serverId, cwd, isDirty: true })).toBe("base");
|
||||
});
|
||||
|
||||
it("invalidates PR detail queries when the prStatus changes, ignoring the volatile requestId", () => {
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
invalidatePrPaneTimelineForCheckout,
|
||||
} from "@/git/query-keys";
|
||||
import { type CheckoutPrStatusPayload, normalizeCheckoutPrStatusPayload } from "@/git/pr-status";
|
||||
import { expireStaleDiffModeOverrides } from "@/review/store";
|
||||
import { expireWorkingDiffComparisons } from "@/git/working-diff-comparison";
|
||||
|
||||
export type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
|
||||
export type { CheckoutPrStatusPayload } from "@/git/pr-status";
|
||||
@@ -31,7 +31,7 @@ export async function fetchCheckoutStatus({
|
||||
cwd: string;
|
||||
}): Promise<CheckoutStatusPayload> {
|
||||
const payload = await client.getCheckoutStatus(cwd);
|
||||
expireStaleDiffModeOverrides({ serverId, cwd, isDirty: payload.isGit && payload.isDirty });
|
||||
expireWorkingDiffComparisons({ serverId, cwd, isDirty: payload.isGit && payload.isDirty });
|
||||
return payload;
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ export function applyCheckoutStatusUpdateFromEvent({
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: checkoutCommitsQueryKey(serverId, payload.cwd),
|
||||
});
|
||||
expireStaleDiffModeOverrides({
|
||||
expireWorkingDiffComparisons({
|
||||
serverId,
|
||||
cwd: payload.cwd,
|
||||
isDirty: payload.isGit && payload.isDirty,
|
||||
|
||||
@@ -187,7 +187,6 @@ interface ChangesSurfaceProps {
|
||||
cwd: string;
|
||||
enabled?: boolean;
|
||||
presentation?: ChangesPresentation;
|
||||
modeScope: string;
|
||||
focusPath?: string;
|
||||
focusRequestId?: number;
|
||||
onOpenFile?: (path: string) => void;
|
||||
@@ -1565,7 +1564,6 @@ export function ChangesSurface({
|
||||
cwd,
|
||||
enabled,
|
||||
presentation = "combined",
|
||||
modeScope,
|
||||
focusPath,
|
||||
focusRequestId,
|
||||
onOpenFile,
|
||||
@@ -1679,7 +1677,6 @@ export function ChangesSurface({
|
||||
cwd,
|
||||
ignoreWhitespace: preferences.hideWhitespace,
|
||||
enabled: enabled !== false,
|
||||
modeScope,
|
||||
});
|
||||
usePublishWorkingDiffAttachment({
|
||||
serverId,
|
||||
|
||||
@@ -5,14 +5,12 @@ import {
|
||||
} from "@/attachments/workspace-attachments-store";
|
||||
import {
|
||||
buildReviewDraftKey,
|
||||
buildReviewDraftScopeKey,
|
||||
useInlineReviewController,
|
||||
useResolvedDiffMode,
|
||||
useReviewAttachmentSnapshot,
|
||||
useSetDiffModeOverride,
|
||||
} from "@/review";
|
||||
import { useCheckoutDiffQuery } from "@/git/use-diff-query";
|
||||
import { useCheckoutStatusQuery } from "@/git/use-status-query";
|
||||
import { useWorkingDiffComparison } from "@/git/working-diff-comparison";
|
||||
|
||||
interface UseWorkingDiffOptions {
|
||||
serverId: string;
|
||||
@@ -21,7 +19,6 @@ interface UseWorkingDiffOptions {
|
||||
ignoreWhitespace: boolean;
|
||||
enabled: boolean;
|
||||
queryScope?: string;
|
||||
modeScope: string;
|
||||
}
|
||||
|
||||
export function useWorkingDiff({
|
||||
@@ -31,7 +28,6 @@ export function useWorkingDiff({
|
||||
ignoreWhitespace,
|
||||
enabled,
|
||||
queryScope,
|
||||
modeScope,
|
||||
}: UseWorkingDiffOptions) {
|
||||
const {
|
||||
status,
|
||||
@@ -50,39 +46,14 @@ export function useWorkingDiff({
|
||||
const currentBranchName =
|
||||
gitStatus?.currentBranch && gitStatus.currentBranch !== "HEAD" ? gitStatus.currentBranch : null;
|
||||
|
||||
const reviewDraftScopeKey = useMemo(
|
||||
() =>
|
||||
buildReviewDraftScopeKey({
|
||||
serverId,
|
||||
workspaceId,
|
||||
cwd,
|
||||
baseRef,
|
||||
ignoreWhitespace,
|
||||
}),
|
||||
[baseRef, cwd, ignoreWhitespace, serverId, workspaceId],
|
||||
);
|
||||
const modeScopeKey = `${reviewDraftScopeKey}:surface=${encodeURIComponent(modeScope)}`;
|
||||
const diffMode = useResolvedDiffMode({
|
||||
scopeKey: modeScopeKey,
|
||||
hasUncommittedChanges,
|
||||
const { comparison: diffMode, selectComparison } = useWorkingDiffComparison({
|
||||
serverId,
|
||||
workspaceId,
|
||||
cwd,
|
||||
isDirty: hasUncommittedChanges,
|
||||
});
|
||||
const setDiffModeOverride = useSetDiffModeOverride();
|
||||
const selectDiffMode = useCallback(
|
||||
(nextMode: "uncommitted" | "base") => {
|
||||
setDiffModeOverride({
|
||||
scopeKey: modeScopeKey,
|
||||
override: {
|
||||
serverId,
|
||||
cwd,
|
||||
mode: nextMode,
|
||||
isDirtyAtSelection: hasUncommittedChanges,
|
||||
},
|
||||
});
|
||||
},
|
||||
[cwd, hasUncommittedChanges, modeScopeKey, serverId, setDiffModeOverride],
|
||||
);
|
||||
const selectUncommitted = useCallback(() => selectDiffMode("uncommitted"), [selectDiffMode]);
|
||||
const selectBase = useCallback(() => selectDiffMode("base"), [selectDiffMode]);
|
||||
const selectUncommitted = useCallback(() => selectComparison("uncommitted"), [selectComparison]);
|
||||
const selectBase = useCallback(() => selectComparison("base"), [selectComparison]);
|
||||
|
||||
const {
|
||||
files,
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useCallback } from "react";
|
||||
import { create } from "zustand";
|
||||
import {
|
||||
expireWorkingDiffComparisonsInState,
|
||||
resolveWorkingDiffComparisonFromState,
|
||||
selectWorkingDiffComparisonInState,
|
||||
type WorkingDiffCheckoutIdentity,
|
||||
type WorkingDiffComparison,
|
||||
type WorkingDiffComparisonState,
|
||||
} from "./state";
|
||||
|
||||
interface WorkingDiffComparisonStore extends WorkingDiffComparisonState {
|
||||
select: (
|
||||
input: WorkingDiffCheckoutIdentity & {
|
||||
comparison: WorkingDiffComparison;
|
||||
isDirty: boolean;
|
||||
},
|
||||
) => void;
|
||||
}
|
||||
|
||||
const useWorkingDiffComparisonStore = create<WorkingDiffComparisonStore>((set) => ({
|
||||
overrides: {},
|
||||
select: (input) => set((state) => selectWorkingDiffComparisonInState(state, input)),
|
||||
}));
|
||||
|
||||
export function useWorkingDiffComparison(
|
||||
input: WorkingDiffCheckoutIdentity & { isDirty: boolean },
|
||||
): {
|
||||
comparison: WorkingDiffComparison;
|
||||
selectComparison: (comparison: WorkingDiffComparison) => void;
|
||||
} {
|
||||
const { serverId, workspaceId, cwd, isDirty } = input;
|
||||
const comparison = useWorkingDiffComparisonStore((state) =>
|
||||
resolveWorkingDiffComparisonFromState(state, { serverId, workspaceId, cwd, isDirty }),
|
||||
);
|
||||
const select = useWorkingDiffComparisonStore((state) => state.select);
|
||||
const selectComparison = useCallback(
|
||||
(next: WorkingDiffComparison) =>
|
||||
select({ serverId, workspaceId, cwd, isDirty, comparison: next }),
|
||||
[cwd, isDirty, select, serverId, workspaceId],
|
||||
);
|
||||
return { comparison, selectComparison };
|
||||
}
|
||||
|
||||
export function selectWorkingDiffComparison(
|
||||
input: WorkingDiffCheckoutIdentity & {
|
||||
comparison: WorkingDiffComparison;
|
||||
isDirty: boolean;
|
||||
},
|
||||
): void {
|
||||
useWorkingDiffComparisonStore.getState().select(input);
|
||||
}
|
||||
|
||||
export function resolveWorkingDiffComparison(
|
||||
input: WorkingDiffCheckoutIdentity & { isDirty: boolean },
|
||||
): WorkingDiffComparison {
|
||||
return resolveWorkingDiffComparisonFromState(useWorkingDiffComparisonStore.getState(), input);
|
||||
}
|
||||
|
||||
export function expireWorkingDiffComparisons(input: {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
isDirty: boolean;
|
||||
}): void {
|
||||
useWorkingDiffComparisonStore.setState((state) =>
|
||||
expireWorkingDiffComparisonsInState(state, input),
|
||||
);
|
||||
}
|
||||
|
||||
export function resetWorkingDiffComparisons(): void {
|
||||
useWorkingDiffComparisonStore.setState({ overrides: {} });
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
expireWorkingDiffComparisonsInState,
|
||||
resolveWorkingDiffComparisonFromState,
|
||||
selectWorkingDiffComparisonInState,
|
||||
type WorkingDiffComparisonState,
|
||||
workingDiffComparisonKey,
|
||||
} from "./state";
|
||||
|
||||
const checkout = { serverId: "server-1", workspaceId: "workspace-1", cwd: "/repo" };
|
||||
|
||||
function emptyState(): WorkingDiffComparisonState {
|
||||
return { overrides: {} };
|
||||
}
|
||||
|
||||
describe("working diff comparison", () => {
|
||||
it("scopes selection to the workspace checkout rather than a panel", () => {
|
||||
expect(workingDiffComparisonKey(checkout)).toBe(
|
||||
"working-diff:server=server-1:workspace=workspace-1",
|
||||
);
|
||||
expect(workingDiffComparisonKey({ ...checkout, workspaceId: "workspace-2" })).not.toBe(
|
||||
workingDiffComparisonKey(checkout),
|
||||
);
|
||||
expect(workingDiffComparisonKey({ ...checkout, workspaceId: null, cwd: "/repo/" })).toBe(
|
||||
"working-diff:server=server-1:cwd=%2Frepo",
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults from checkout dirtiness and honors a matching manual selection", () => {
|
||||
expect(
|
||||
resolveWorkingDiffComparisonFromState(emptyState(), { ...checkout, isDirty: true }),
|
||||
).toBe("uncommitted");
|
||||
expect(
|
||||
resolveWorkingDiffComparisonFromState(emptyState(), { ...checkout, isDirty: false }),
|
||||
).toBe("base");
|
||||
|
||||
const selected = selectWorkingDiffComparisonInState(emptyState(), {
|
||||
...checkout,
|
||||
comparison: "base",
|
||||
isDirty: true,
|
||||
});
|
||||
expect(resolveWorkingDiffComparisonFromState(selected, { ...checkout, isDirty: true })).toBe(
|
||||
"base",
|
||||
);
|
||||
});
|
||||
|
||||
it("masks and expires stale selections for every workspace on the checkout", () => {
|
||||
let state = selectWorkingDiffComparisonInState(emptyState(), {
|
||||
...checkout,
|
||||
comparison: "base",
|
||||
isDirty: true,
|
||||
});
|
||||
state = selectWorkingDiffComparisonInState(state, {
|
||||
...checkout,
|
||||
workspaceId: "workspace-2",
|
||||
comparison: "uncommitted",
|
||||
isDirty: false,
|
||||
});
|
||||
state = selectWorkingDiffComparisonInState(state, {
|
||||
serverId: "server-2",
|
||||
workspaceId: "workspace-3",
|
||||
cwd: "/other",
|
||||
comparison: "base",
|
||||
isDirty: true,
|
||||
});
|
||||
|
||||
expect(resolveWorkingDiffComparisonFromState(state, { ...checkout, isDirty: false })).toBe(
|
||||
"base",
|
||||
);
|
||||
const expired = expireWorkingDiffComparisonsInState(state, {
|
||||
serverId: checkout.serverId,
|
||||
cwd: checkout.cwd,
|
||||
isDirty: false,
|
||||
});
|
||||
expect(expired.overrides[workingDiffComparisonKey(checkout)]).toBeUndefined();
|
||||
expect(
|
||||
expired.overrides[workingDiffComparisonKey({ ...checkout, workspaceId: "workspace-2" })],
|
||||
).toBeDefined();
|
||||
expect(
|
||||
expired.overrides[
|
||||
workingDiffComparisonKey({
|
||||
serverId: "server-2",
|
||||
workspaceId: "workspace-3",
|
||||
cwd: "/other",
|
||||
})
|
||||
],
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("keeps state identity when nothing expires", () => {
|
||||
const state = selectWorkingDiffComparisonInState(emptyState(), {
|
||||
...checkout,
|
||||
comparison: "base",
|
||||
isDirty: true,
|
||||
});
|
||||
expect(
|
||||
expireWorkingDiffComparisonsInState(state, {
|
||||
serverId: checkout.serverId,
|
||||
cwd: checkout.cwd,
|
||||
isDirty: true,
|
||||
}),
|
||||
).toBe(state);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
export type WorkingDiffComparison = "uncommitted" | "base";
|
||||
|
||||
export interface WorkingDiffComparisonOverride {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
comparison: WorkingDiffComparison;
|
||||
isDirtyAtSelection: boolean;
|
||||
}
|
||||
|
||||
export interface WorkingDiffComparisonState {
|
||||
overrides: Record<string, WorkingDiffComparisonOverride>;
|
||||
}
|
||||
|
||||
export interface WorkingDiffCheckoutIdentity {
|
||||
serverId: string;
|
||||
workspaceId?: string | null;
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
function normalizeCwd(cwd: string): string {
|
||||
const trimmed = cwd.trim();
|
||||
return trimmed === "/" ? trimmed : trimmed.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
export function workingDiffComparisonKey(input: WorkingDiffCheckoutIdentity): string {
|
||||
const workspaceId = input.workspaceId?.trim();
|
||||
const checkout = workspaceId
|
||||
? `workspace=${encodeURIComponent(workspaceId)}`
|
||||
: `cwd=${encodeURIComponent(normalizeCwd(input.cwd))}`;
|
||||
return `working-diff:server=${encodeURIComponent(input.serverId.trim())}:${checkout}`;
|
||||
}
|
||||
|
||||
export function selectWorkingDiffComparisonInState(
|
||||
state: WorkingDiffComparisonState,
|
||||
input: WorkingDiffCheckoutIdentity & {
|
||||
comparison: WorkingDiffComparison;
|
||||
isDirty: boolean;
|
||||
},
|
||||
): WorkingDiffComparisonState {
|
||||
return {
|
||||
overrides: {
|
||||
...state.overrides,
|
||||
[workingDiffComparisonKey(input)]: {
|
||||
serverId: input.serverId.trim(),
|
||||
cwd: normalizeCwd(input.cwd),
|
||||
comparison: input.comparison,
|
||||
isDirtyAtSelection: input.isDirty,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveWorkingDiffComparisonFromState(
|
||||
state: WorkingDiffComparisonState,
|
||||
input: WorkingDiffCheckoutIdentity & { isDirty: boolean },
|
||||
): WorkingDiffComparison {
|
||||
const override = state.overrides[workingDiffComparisonKey(input)];
|
||||
// Status can render before boundary expiry runs, so resolution must also mask a stale
|
||||
// selection under any ordering of the two updates.
|
||||
if (override?.isDirtyAtSelection === input.isDirty) {
|
||||
return override.comparison;
|
||||
}
|
||||
return input.isDirty ? "uncommitted" : "base";
|
||||
}
|
||||
|
||||
export function expireWorkingDiffComparisonsInState(
|
||||
state: WorkingDiffComparisonState,
|
||||
input: { serverId: string; cwd: string; isDirty: boolean },
|
||||
): WorkingDiffComparisonState {
|
||||
// Expire at the status boundary so selections cannot return after an unmounted surface
|
||||
// misses a dirty-state transition.
|
||||
const staleKeys = Object.entries(state.overrides)
|
||||
.filter(
|
||||
([, override]) =>
|
||||
override.serverId === input.serverId.trim() &&
|
||||
override.cwd === normalizeCwd(input.cwd) &&
|
||||
override.isDirtyAtSelection !== input.isDirty,
|
||||
)
|
||||
.map(([key]) => key);
|
||||
if (staleKeys.length === 0) return state;
|
||||
|
||||
const overrides = { ...state.overrides };
|
||||
for (const key of staleKeys) delete overrides[key];
|
||||
return { overrides };
|
||||
}
|
||||
@@ -13,6 +13,8 @@ import { useAutocomplete } from "./use-autocomplete";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { CLIENT_SLASH_COMMANDS, type ClientSlashCommand } from "@/client-slash-commands";
|
||||
import type { PluginClientSlashCommand } from "@/plugins/client-slash-commands";
|
||||
import { mergeSlashCommandSources } from "@/plugins/client-slash-commands/model";
|
||||
import {
|
||||
applySlashCommandReplacement,
|
||||
filterAndRankCommandAutocompleteEntries,
|
||||
@@ -36,6 +38,7 @@ interface UseAgentAutocompleteInput {
|
||||
onAutocompleteApplied?: () => void;
|
||||
onClientSlashCommand?: (command: ClientSlashCommand) => void;
|
||||
canExecuteClientSlashCommand?: boolean;
|
||||
pluginClientSlashCommands?: readonly PluginClientSlashCommand[];
|
||||
}
|
||||
|
||||
interface AgentAutocompleteKeyPressEvent {
|
||||
@@ -51,6 +54,10 @@ interface AgentAutocompleteInputSnapshot {
|
||||
|
||||
type AgentAutocompleteOption =
|
||||
| (AutocompleteOption & { type: "client_command"; command: ClientSlashCommand })
|
||||
| (AutocompleteOption & {
|
||||
type: "plugin_command";
|
||||
command: PluginClientSlashCommand;
|
||||
})
|
||||
| (AutocompleteOption & { type: "provider_command" })
|
||||
| (AutocompleteOption & {
|
||||
type: "workspace_entry";
|
||||
@@ -107,6 +114,7 @@ interface DirectorySuggestionEntry {
|
||||
|
||||
type AvailableCommand =
|
||||
| { source: "client"; command: ClientSlashCommand }
|
||||
| { source: "plugin"; command: PluginClientSlashCommand }
|
||||
| { source: "provider"; command: AgentSlashCommand };
|
||||
|
||||
function normalizeDraftCommandConfig(
|
||||
@@ -175,6 +183,9 @@ function mapCommandToOption(entry: AvailableCommand, t: TFunction): AgentAutocom
|
||||
command: entry.command,
|
||||
};
|
||||
}
|
||||
if (entry.source === "plugin") {
|
||||
return { ...base, type: "plugin_command", command: entry.command };
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
type: "provider_command",
|
||||
@@ -187,6 +198,7 @@ interface BuildAutocompleteOptionsInput {
|
||||
isVisible: boolean;
|
||||
mode: AutocompleteMode;
|
||||
commands: AgentSlashCommand[];
|
||||
pluginCommands: readonly PluginClientSlashCommand[];
|
||||
isDraftContext: boolean;
|
||||
commandFilterQuery: string;
|
||||
activeSlashCommand: SlashCommandRange | null;
|
||||
@@ -201,19 +213,26 @@ function buildCommandAutocompleteOptions(input: BuildAutocompleteOptionsInput) {
|
||||
}
|
||||
|
||||
if (input.mode === "command") {
|
||||
const providerCommands = input.commands.map(
|
||||
(command): AvailableCommand => ({ source: "provider", command }),
|
||||
);
|
||||
const clientCommandNames = new Set(CLIENT_SLASH_COMMANDS.map((command) => command.name));
|
||||
const rootCommands: AvailableCommand[] = input.isDraftContext
|
||||
? providerCommands
|
||||
: [
|
||||
...CLIENT_SLASH_COMMANDS.map(
|
||||
(command): AvailableCommand => ({ source: "client", command }),
|
||||
),
|
||||
...providerCommands.filter((entry) => !clientCommandNames.has(entry.command.name)),
|
||||
];
|
||||
const availableCommands =
|
||||
const providerCommands = input.commands.map((command) => ({
|
||||
source: "provider" as const,
|
||||
command,
|
||||
}));
|
||||
const rootCommands: AvailableCommand[] = mergeSlashCommandSources({
|
||||
builtIn: CLIENT_SLASH_COMMANDS,
|
||||
plugins: input.pluginCommands,
|
||||
provider: input.commands,
|
||||
onPluginCollision(command, winner) {
|
||||
console.warn(
|
||||
`[Plugins] Client slash command /${command.name} from ${command.pluginId} ignored; ${winner} command wins`,
|
||||
);
|
||||
},
|
||||
})
|
||||
.filter((entry) => !input.isDraftContext || entry.source !== "built-in")
|
||||
.map((entry): AvailableCommand => {
|
||||
if (entry.source === "built-in") return { source: "client", command: entry.command };
|
||||
return entry;
|
||||
});
|
||||
const availableCommands: AvailableCommand[] =
|
||||
input.activeSlashCommand?.position === "inline"
|
||||
? filterInlineSkillCommandEntries(providerCommands)
|
||||
: rootCommands;
|
||||
@@ -330,6 +349,7 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
|
||||
onAutocompleteApplied,
|
||||
onClientSlashCommand,
|
||||
canExecuteClientSlashCommand,
|
||||
pluginClientSlashCommands = [],
|
||||
} = input;
|
||||
|
||||
const activeSlashCommand = useMemo(
|
||||
@@ -446,6 +466,7 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
|
||||
activeFileMention,
|
||||
commandFilterQuery,
|
||||
commands,
|
||||
pluginCommands: pluginClientSlashCommands,
|
||||
activeSlashCommand,
|
||||
fileSuggestions: fileSuggestionsQuery.data ?? [],
|
||||
isDraftContext,
|
||||
@@ -458,6 +479,7 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
|
||||
activeSlashCommand,
|
||||
commandFilterQuery,
|
||||
commands,
|
||||
pluginClientSlashCommands,
|
||||
fileSuggestionsQuery.data,
|
||||
isDraftContext,
|
||||
isVisible,
|
||||
@@ -477,7 +499,9 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
|
||||
activeFileMention,
|
||||
});
|
||||
const selectedIsCommand =
|
||||
selected.type === "client_command" || selected.type === "provider_command";
|
||||
selected.type === "client_command" ||
|
||||
selected.type === "plugin_command" ||
|
||||
selected.type === "provider_command";
|
||||
if (snapshot && selectedIsCommand && !current.slashCommand) return;
|
||||
if (
|
||||
selected.type === "client_command" &&
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
sanitizeFontFamily,
|
||||
saveAppSettings as saveAppSettingsPure,
|
||||
type AppSettings,
|
||||
type AppSettingsUpdate,
|
||||
type OpenInSidePanePreferences,
|
||||
type PullRequestOpenLocation,
|
||||
type DesktopSettingsBridge,
|
||||
@@ -72,6 +73,7 @@ export {
|
||||
};
|
||||
export type {
|
||||
AppSettings,
|
||||
AppSettingsUpdate,
|
||||
AppLanguage,
|
||||
OpenInSidePanePreferences,
|
||||
PullRequestOpenLocation,
|
||||
@@ -116,7 +118,7 @@ export interface UseAppSettingsReturn {
|
||||
settings: AppSettings;
|
||||
isLoading: boolean;
|
||||
error: unknown;
|
||||
updateSettings: (updates: Partial<AppSettings>) => Promise<void>;
|
||||
updateSettings: (updates: AppSettingsUpdate) => Promise<void>;
|
||||
resetSettings: () => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -140,7 +142,7 @@ export function useAppSettings(): UseAppSettingsReturn {
|
||||
});
|
||||
|
||||
const updateSettings = useCallback(
|
||||
async (updates: Partial<AppSettings>) => {
|
||||
async (updates: AppSettingsUpdate) => {
|
||||
try {
|
||||
await saveAppSettings({ queryClient, updates });
|
||||
} catch (err) {
|
||||
@@ -242,7 +244,7 @@ export async function persistAppSettings(updates: Partial<AppSettings>): Promise
|
||||
|
||||
export async function saveAppSettings(input: {
|
||||
queryClient: QueryClient;
|
||||
updates: Partial<AppSettings>;
|
||||
updates: AppSettingsUpdate;
|
||||
deps?: SettingsDeps;
|
||||
}): Promise<void> {
|
||||
await saveAppSettingsPure({
|
||||
|
||||
@@ -197,6 +197,46 @@ describe("loadAppSettingsFromStorage", () => {
|
||||
expect(result.chatOutlineEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it("defaults sidebar navigation items to an empty preference list", async () => {
|
||||
const deps = makeDeps();
|
||||
|
||||
const result = await loadAppSettingsFromStorage(deps);
|
||||
|
||||
expect(result.sidebarNavItems).toEqual([]);
|
||||
});
|
||||
|
||||
it("loads stored sidebar navigation items in order", async () => {
|
||||
const deps = makeDeps({
|
||||
storage: createInMemoryKeyValueStorage({
|
||||
[APP_SETTINGS_KEY]: JSON.stringify({
|
||||
sidebarNavItems: [
|
||||
{ key: "history", visible: false },
|
||||
{ key: "new-workspace", visible: true },
|
||||
],
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await loadAppSettingsFromStorage(deps);
|
||||
|
||||
expect(result.sidebarNavItems).toEqual([
|
||||
{ key: "history", visible: false },
|
||||
{ key: "new-workspace", visible: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to the default sidebar navigation items when the stored list is malformed", async () => {
|
||||
const deps = makeDeps({
|
||||
storage: createInMemoryKeyValueStorage({
|
||||
[APP_SETTINGS_KEY]: JSON.stringify({ sidebarNavItems: [{ key: 3 }] }),
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await loadAppSettingsFromStorage(deps);
|
||||
|
||||
expect(result.sidebarNavItems).toEqual([]);
|
||||
});
|
||||
|
||||
it("collapses legacy diff destinations into the former Explorer choice", async () => {
|
||||
const deps = makeDeps({
|
||||
storage: createInMemoryKeyValueStorage({
|
||||
@@ -380,6 +420,44 @@ describe("loadAppSettingsFromStorage", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveAppSettings", () => {
|
||||
it("applies consecutive functional updates to the latest cached settings", async () => {
|
||||
const deps = makeDeps();
|
||||
const queryClient = new QueryClient();
|
||||
queryClient.setQueryData(APP_SETTINGS_QUERY_KEY, DEFAULT_CLIENT_SETTINGS);
|
||||
|
||||
await Promise.all([
|
||||
saveAppSettings({
|
||||
queryClient,
|
||||
updates: (current) => ({
|
||||
sidebarNavItems: [...current.sidebarNavItems, { key: "history", visible: false }],
|
||||
}),
|
||||
deps,
|
||||
}),
|
||||
saveAppSettings({
|
||||
queryClient,
|
||||
updates: (current) => ({
|
||||
sidebarNavItems: [...current.sidebarNavItems, { key: "search", visible: true }],
|
||||
}),
|
||||
deps,
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(queryClient.getQueryData(APP_SETTINGS_QUERY_KEY)).toMatchObject({
|
||||
sidebarNavItems: [
|
||||
{ key: "history", visible: false },
|
||||
{ key: "search", visible: true },
|
||||
],
|
||||
});
|
||||
expect(JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "null")).toMatchObject({
|
||||
sidebarNavItems: [
|
||||
{ key: "history", visible: false },
|
||||
{ key: "search", visible: true },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadSettingsFromStorage", () => {
|
||||
it("defaults built-in daemon management to enabled when storage is empty", async () => {
|
||||
const deps = makeDeps();
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ActiveTurnBehavior } from "@getpaseo/protocol/messages";
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import type { DesktopSettings } from "@/desktop/settings/desktop-settings";
|
||||
import type { AppLanguage } from "@/i18n/locales";
|
||||
import type { SidebarNavPreference } from "@/sidebar-nav/model";
|
||||
import {
|
||||
DEFAULT_SIDEBAR_CHECKS_DISPLAY,
|
||||
type SidebarChecksDisplay,
|
||||
@@ -82,6 +83,8 @@ export interface AppSettings {
|
||||
sidebarWorkspaceTrailing: SidebarWorkspaceTrailing;
|
||||
sidebarRowItems: SidebarRowItems;
|
||||
sidebarChecksDisplay: SidebarChecksDisplay;
|
||||
/** Top-level sidebar rows in display order; empty means the default order, all visible. */
|
||||
sidebarNavItems: SidebarNavPreference[];
|
||||
autoExpandReasoning: boolean;
|
||||
toolCallDetailLevel: ToolCallDetailLevel;
|
||||
chatOutlineEnabled: boolean;
|
||||
@@ -91,6 +94,10 @@ export interface AppSettings {
|
||||
pullRequestOpenLocation: PullRequestOpenLocation;
|
||||
}
|
||||
|
||||
export type AppSettingsUpdate =
|
||||
| Partial<AppSettings>
|
||||
| ((current: AppSettings) => Partial<AppSettings>);
|
||||
|
||||
export interface OpenInSidePanePreferences {
|
||||
explorerFiles: boolean;
|
||||
diffs: boolean;
|
||||
@@ -130,6 +137,7 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
|
||||
sidebarWorkspaceTrailing: "diff",
|
||||
sidebarRowItems: DEFAULT_SIDEBAR_ROW_ITEMS,
|
||||
sidebarChecksDisplay: DEFAULT_SIDEBAR_CHECKS_DISPLAY,
|
||||
sidebarNavItems: [],
|
||||
autoExpandReasoning: false,
|
||||
toolCallDetailLevel: "detailed",
|
||||
chatOutlineEnabled: true,
|
||||
@@ -218,6 +226,7 @@ const StoredAppSettingsSchema = z
|
||||
.enum(["iconAndText", "icon", "none"])
|
||||
.optional()
|
||||
.catch(DEFAULT_SIDEBAR_CHECKS_DISPLAY),
|
||||
sidebarNavItems: z.array(z.object({ key: z.string(), visible: z.boolean() })).catch([]),
|
||||
autoExpandReasoning: z.boolean().catch(false),
|
||||
toolCallDetailLevel: z
|
||||
.enum(["overview", "detailed"])
|
||||
@@ -319,14 +328,15 @@ export interface SettingsDeps {
|
||||
|
||||
export async function saveAppSettings(input: {
|
||||
queryClient: QueryClient;
|
||||
updates: Partial<AppSettings>;
|
||||
updates: AppSettingsUpdate;
|
||||
deps: SettingsDeps;
|
||||
}): Promise<void> {
|
||||
const storedCurrent =
|
||||
input.queryClient.getQueryData<AppSettings>(APP_SETTINGS_QUERY_KEY) ??
|
||||
(await loadAppSettingsFromStorage(input.deps));
|
||||
const current = normalizeAppSettings(storedCurrent);
|
||||
const next = { ...current, ...input.updates };
|
||||
const updates = typeof input.updates === "function" ? input.updates(current) : input.updates;
|
||||
const next = { ...current, ...updates };
|
||||
input.queryClient.setQueryData<AppSettings>(APP_SETTINGS_QUERY_KEY, next);
|
||||
await writeAppSettings(
|
||||
input.deps.storage,
|
||||
|
||||
@@ -5,12 +5,14 @@ import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-over
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
import { getIsElectronRuntime } from "@/constants/layout";
|
||||
|
||||
export function useShortcutKeys(actionId: string): ShortcutKey[][] | null {
|
||||
/** `null` action ids are accepted so callers can keep the hook unconditional. */
|
||||
export function useShortcutKeys(actionId: string | null): ShortcutKey[][] | null {
|
||||
const { overrides } = useKeyboardShortcutOverrides();
|
||||
const isMac = getShortcutOs() === "mac";
|
||||
const isDesktopApp = getIsElectronRuntime();
|
||||
|
||||
return useMemo(() => {
|
||||
if (actionId === null) return null;
|
||||
return resolveShortcutKeysForAction(actionId, overrides, {
|
||||
isMac,
|
||||
isDesktop: isDesktopApp,
|
||||
|
||||
@@ -1099,6 +1099,7 @@ export const ar: TranslationResources = {
|
||||
},
|
||||
sections: {
|
||||
sessions: "السجل",
|
||||
search: "بحث",
|
||||
schedules: "الجداول",
|
||||
},
|
||||
worktreeSetup: {
|
||||
@@ -2096,6 +2097,12 @@ export const ar: TranslationResources = {
|
||||
title: "مخطط المحادثة",
|
||||
description: "عرض مخطط للتنقل بين المطالبات",
|
||||
},
|
||||
sidebar: {
|
||||
title: "الشريط الجانبي",
|
||||
description: "اختر العناصر التي تظهر أعلى الشريط الجانبي وترتيبها",
|
||||
moveUp: "نقل لأعلى",
|
||||
moveDown: "نقل لأسفل",
|
||||
},
|
||||
fonts: {
|
||||
title: "الخطوط",
|
||||
systemDefault: "الافتراضي للنظام",
|
||||
|
||||
@@ -1108,6 +1108,7 @@ export const en = {
|
||||
},
|
||||
sections: {
|
||||
sessions: "History",
|
||||
search: "Search",
|
||||
schedules: "Schedules",
|
||||
},
|
||||
worktreeSetup: {
|
||||
@@ -2195,6 +2196,12 @@ export const en = {
|
||||
title: "Chat outline",
|
||||
description: "Show an outline for jumping between prompts",
|
||||
},
|
||||
sidebar: {
|
||||
title: "Sidebar",
|
||||
description: "Choose which items appear at the top of the sidebar and in what order",
|
||||
moveUp: "Move up",
|
||||
moveDown: "Move down",
|
||||
},
|
||||
fonts: {
|
||||
title: "Fonts",
|
||||
systemDefault: "System default",
|
||||
|
||||
@@ -1133,6 +1133,7 @@ export const es: TranslationResources = {
|
||||
},
|
||||
sections: {
|
||||
sessions: "Historial",
|
||||
search: "Buscar",
|
||||
schedules: "Horarios",
|
||||
},
|
||||
worktreeSetup: {
|
||||
@@ -2146,6 +2147,13 @@ export const es: TranslationResources = {
|
||||
title: "Esquema del chat",
|
||||
description: "Muestra un esquema para saltar entre instrucciones",
|
||||
},
|
||||
sidebar: {
|
||||
title: "Barra lateral",
|
||||
description:
|
||||
"Elige qué elementos aparecen en la parte superior de la barra lateral y en qué orden",
|
||||
moveUp: "Mover hacia arriba",
|
||||
moveDown: "Mover hacia abajo",
|
||||
},
|
||||
fonts: {
|
||||
title: "Fuentes",
|
||||
systemDefault: "Valor predeterminado del sistema",
|
||||
|
||||
@@ -1133,6 +1133,7 @@ export const fr: TranslationResources = {
|
||||
},
|
||||
sections: {
|
||||
sessions: "Historique",
|
||||
search: "Rechercher",
|
||||
schedules: "Planifications",
|
||||
},
|
||||
worktreeSetup: {
|
||||
@@ -2150,6 +2151,12 @@ export const fr: TranslationResources = {
|
||||
title: "Plan de la discussion",
|
||||
description: "Afficher un plan pour passer d’une requête à l’autre",
|
||||
},
|
||||
sidebar: {
|
||||
title: "Barre latérale",
|
||||
description: "Choisissez les éléments affichés en haut de la barre latérale et leur ordre",
|
||||
moveUp: "Déplacer vers le haut",
|
||||
moveDown: "Déplacer vers le bas",
|
||||
},
|
||||
fonts: {
|
||||
title: "Polices",
|
||||
systemDefault: "Valeur par défaut du système",
|
||||
|
||||
@@ -1111,6 +1111,7 @@ export const ja: TranslationResources = {
|
||||
},
|
||||
sections: {
|
||||
sessions: "履歴",
|
||||
search: "検索",
|
||||
schedules: "スケジュール",
|
||||
},
|
||||
worktreeSetup: {
|
||||
@@ -2113,6 +2114,12 @@ export const ja: TranslationResources = {
|
||||
title: "チャットのアウトライン",
|
||||
description: "プロンプト間を移動するためのアウトラインを表示します",
|
||||
},
|
||||
sidebar: {
|
||||
title: "サイドバー",
|
||||
description: "サイドバー上部に表示する項目とその順序を選択します",
|
||||
moveUp: "上に移動",
|
||||
moveDown: "下に移動",
|
||||
},
|
||||
fonts: {
|
||||
title: "フォント",
|
||||
systemDefault: "システムデフォルト",
|
||||
|
||||
@@ -1106,6 +1106,7 @@ export const ko: TranslationResources = {
|
||||
},
|
||||
sections: {
|
||||
sessions: "기록",
|
||||
search: "검색",
|
||||
schedules: "일정",
|
||||
},
|
||||
worktreeSetup: {
|
||||
@@ -2107,6 +2108,12 @@ export const ko: TranslationResources = {
|
||||
title: "채팅 개요",
|
||||
description: "프롬프트 사이를 이동하기 위한 개요 표시",
|
||||
},
|
||||
sidebar: {
|
||||
title: "사이드바",
|
||||
description: "사이드바 상단에 표시할 항목과 순서를 선택하세요",
|
||||
moveUp: "위로 이동",
|
||||
moveDown: "아래로 이동",
|
||||
},
|
||||
fonts: {
|
||||
title: "글꼴",
|
||||
systemDefault: "시스템 기본값",
|
||||
|
||||
@@ -1124,6 +1124,7 @@ export const ptBR: TranslationResources = {
|
||||
},
|
||||
sections: {
|
||||
sessions: "Histórico",
|
||||
search: "Buscar",
|
||||
schedules: "Agendamentos",
|
||||
},
|
||||
worktreeSetup: {
|
||||
@@ -2130,6 +2131,12 @@ export const ptBR: TranslationResources = {
|
||||
title: "Estrutura do chat",
|
||||
description: "Mostrar uma estrutura para navegar entre prompts",
|
||||
},
|
||||
sidebar: {
|
||||
title: "Barra lateral",
|
||||
description: "Escolha quais itens aparecem no topo da barra lateral e em que ordem",
|
||||
moveUp: "Mover para cima",
|
||||
moveDown: "Mover para baixo",
|
||||
},
|
||||
fonts: {
|
||||
title: "Fontes",
|
||||
systemDefault: "Sistema padrão",
|
||||
|
||||
@@ -1115,6 +1115,7 @@ export const ru: TranslationResources = {
|
||||
},
|
||||
sections: {
|
||||
sessions: "История",
|
||||
search: "Поиск",
|
||||
schedules: "Расписания",
|
||||
},
|
||||
worktreeSetup: {
|
||||
@@ -2132,6 +2133,13 @@ export const ru: TranslationResources = {
|
||||
title: "Оглавление чата",
|
||||
description: "Показывать оглавление для перехода между запросами",
|
||||
},
|
||||
sidebar: {
|
||||
title: "Боковая панель",
|
||||
description:
|
||||
"Выберите, какие элементы отображаются вверху боковой панели и в каком порядке",
|
||||
moveUp: "Переместить вверх",
|
||||
moveDown: "Переместить вниз",
|
||||
},
|
||||
fonts: {
|
||||
title: "Шрифты",
|
||||
systemDefault: "Системный шрифт",
|
||||
|
||||
@@ -1091,6 +1091,7 @@ export const zhCN: TranslationResources = {
|
||||
},
|
||||
sections: {
|
||||
sessions: "历史",
|
||||
search: "搜索",
|
||||
schedules: "计划",
|
||||
},
|
||||
worktreeSetup: {
|
||||
@@ -2072,6 +2073,12 @@ export const zhCN: TranslationResources = {
|
||||
title: "聊天大纲",
|
||||
description: "显示用于在提示词之间跳转的大纲",
|
||||
},
|
||||
sidebar: {
|
||||
title: "侧边栏",
|
||||
description: "选择侧边栏顶部显示的项目及其顺序",
|
||||
moveUp: "上移",
|
||||
moveDown: "下移",
|
||||
},
|
||||
fonts: {
|
||||
title: "字体",
|
||||
systemDefault: "系统默认",
|
||||
|
||||
@@ -135,7 +135,6 @@ function ChangesPanel() {
|
||||
cwd={cwd}
|
||||
enabled={isActive}
|
||||
presentation={presentation}
|
||||
modeScope={tabId}
|
||||
focusPath={target.kind === "working_diff" ? target.focusPath : undefined}
|
||||
focusRequestId={target.kind === "working_diff" ? target.focusRequestId : undefined}
|
||||
onSelectDiffFile={isTree ? handleSelectDiffFile : undefined}
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { useHostFeature } from "@/runtime/host-features";
|
||||
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { pluginRegistry } from "./registry";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
|
||||
export function PluginCatalogSync({
|
||||
serverId,
|
||||
@@ -32,15 +31,10 @@ export function PluginCatalogSync({
|
||||
.getPluginCatalog()
|
||||
.then((catalog) => {
|
||||
if (!cancelled) {
|
||||
const timelineChanged = pluginRegistry.installCatalog(serverId, catalog, {
|
||||
pluginRegistry.installCatalog(serverId, catalog, {
|
||||
replacePluginId,
|
||||
client,
|
||||
});
|
||||
if (timelineChanged) {
|
||||
useSessionStore
|
||||
.getState()
|
||||
.sessions[serverId]?.viewedTimelineSync?.reprojectVisibleTimelines();
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
})
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useMemo } from "react";
|
||||
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
||||
import { createPluginAgentActionContext, createPluginWorkspaceActionContext } from "../actions";
|
||||
import { createPluginClientStateSource } from "../client-state/source";
|
||||
import { createPluginNavigation } from "../navigation";
|
||||
import { useInstalledPlugins } from "../registry";
|
||||
import { createPluginSurfaceRuntime } from "../surface-runtime";
|
||||
|
||||
export interface PluginClientSlashCommand {
|
||||
pluginId: string;
|
||||
name: string;
|
||||
description: string;
|
||||
argumentHint: string;
|
||||
run(args: string): Promise<void>;
|
||||
}
|
||||
|
||||
export function usePluginClientSlashCommands(input: {
|
||||
serverId: string;
|
||||
workspaceId: string | null | undefined;
|
||||
agentId: string;
|
||||
}): PluginClientSlashCommand[] {
|
||||
const client = useHostRuntimeClient(input.serverId);
|
||||
const installed = useInstalledPlugins();
|
||||
return useMemo(() => {
|
||||
if (!client || !input.workspaceId) return [];
|
||||
const workspaceId = input.workspaceId;
|
||||
const state = createPluginClientStateSource(input.serverId);
|
||||
const navigation = createPluginNavigation({
|
||||
serverId: input.serverId,
|
||||
workspaceId,
|
||||
});
|
||||
const commands = installed
|
||||
.filter((plugin) => plugin.serverId === input.serverId)
|
||||
.flatMap((plugin) => {
|
||||
const runtime = createPluginSurfaceRuntime(client, plugin.id);
|
||||
if (!runtime) return [];
|
||||
return (plugin.clientSlashCommands ?? []).flatMap((contribution) => {
|
||||
const context =
|
||||
contribution.context === "agent"
|
||||
? createPluginAgentActionContext({
|
||||
plugin,
|
||||
runtime,
|
||||
navigation,
|
||||
state,
|
||||
workspaceId,
|
||||
agentId: input.agentId,
|
||||
})
|
||||
: createPluginWorkspaceActionContext({
|
||||
plugin,
|
||||
runtime,
|
||||
navigation,
|
||||
state,
|
||||
workspaceId,
|
||||
});
|
||||
if (!context) return [];
|
||||
return [
|
||||
{
|
||||
pluginId: plugin.id,
|
||||
name: contribution.name,
|
||||
description: contribution.description,
|
||||
argumentHint: contribution.argumentHint,
|
||||
async run(args: string) {
|
||||
if (contribution.context === "agent" && context.context === "agent") {
|
||||
await contribution.onSubmit({ ...context, args });
|
||||
} else if (
|
||||
contribution.context === "workspace" &&
|
||||
context.context === "workspace"
|
||||
) {
|
||||
await contribution.onSubmit({ ...context, args });
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
});
|
||||
});
|
||||
return commands;
|
||||
}, [client, input.agentId, input.serverId, input.workspaceId, installed]);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
executePluginClientSlashCommand,
|
||||
mergeSlashCommandSources,
|
||||
resolvePluginClientSlashCommand,
|
||||
} from "./model";
|
||||
|
||||
describe("plugin client slash commands", () => {
|
||||
it("uses built-in, plugin, then provider precedence", () => {
|
||||
const collision = vi.fn();
|
||||
const result = mergeSlashCommandSources({
|
||||
builtIn: [{ name: "clear", aliases: ["new"] }],
|
||||
plugins: [
|
||||
{ name: "clear", pluginId: "review" },
|
||||
{ name: "new", pluginId: "alias-collision" },
|
||||
{ name: "review", pluginId: "alpha" },
|
||||
{ name: "review", pluginId: "beta" },
|
||||
],
|
||||
provider: [{ name: "review" }, { name: "usage" }],
|
||||
onPluginCollision: collision,
|
||||
});
|
||||
|
||||
expect(result.map(({ source, command }) => `${source}:${command.name}`)).toEqual([
|
||||
"built-in:clear",
|
||||
"plugin:review",
|
||||
"provider:usage",
|
||||
]);
|
||||
expect(collision).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("passes the trimmed raw remainder as args", () => {
|
||||
const command = { name: "review" };
|
||||
expect(
|
||||
resolvePluginClientSlashCommand({
|
||||
text: " /review foo bar ",
|
||||
hasAttachments: false,
|
||||
commands: [command],
|
||||
}),
|
||||
).toEqual({ command, args: "foo bar" });
|
||||
expect(
|
||||
resolvePluginClientSlashCommand({
|
||||
text: "/review foo",
|
||||
hasAttachments: true,
|
||||
commands: [command],
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("runs the client handler and reports failures", async () => {
|
||||
const run = vi.fn(async (_args: string) => undefined);
|
||||
const onError = vi.fn();
|
||||
await executePluginClientSlashCommand({ command: { run }, args: "foo", onError });
|
||||
expect(run).toHaveBeenCalledWith("foo");
|
||||
expect(onError).not.toHaveBeenCalled();
|
||||
|
||||
const failure = new Error("review failed");
|
||||
await executePluginClientSlashCommand({
|
||||
command: { run: vi.fn().mockRejectedValue(failure) },
|
||||
args: "bar",
|
||||
onError,
|
||||
});
|
||||
expect(onError).toHaveBeenCalledWith(failure);
|
||||
});
|
||||
|
||||
it("does not make the composer wait for a never-resolving handler", () => {
|
||||
const run = vi.fn(() => new Promise<void>(() => undefined));
|
||||
|
||||
expect(
|
||||
executePluginClientSlashCommand({ command: { run }, args: "foo", onError: vi.fn() }),
|
||||
).toBeUndefined();
|
||||
expect(run).toHaveBeenCalledWith("foo");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
export interface SlashCommandDescriptor {
|
||||
name: string;
|
||||
aliases?: readonly string[];
|
||||
}
|
||||
|
||||
export type AvailableSlashCommand<
|
||||
BuiltIn extends SlashCommandDescriptor,
|
||||
Plugin extends SlashCommandDescriptor,
|
||||
Provider extends SlashCommandDescriptor,
|
||||
> =
|
||||
| { source: "built-in"; command: BuiltIn }
|
||||
| { source: "plugin"; command: Plugin }
|
||||
| { source: "provider"; command: Provider };
|
||||
|
||||
export function mergeSlashCommandSources<
|
||||
BuiltIn extends SlashCommandDescriptor,
|
||||
Plugin extends SlashCommandDescriptor,
|
||||
Provider extends SlashCommandDescriptor,
|
||||
>(input: {
|
||||
builtIn: readonly BuiltIn[];
|
||||
plugins: readonly Plugin[];
|
||||
provider: readonly Provider[];
|
||||
onPluginCollision(command: Plugin, winner: "built-in" | "plugin"): void;
|
||||
}): Array<AvailableSlashCommand<BuiltIn, Plugin, Provider>> {
|
||||
const commands: Array<AvailableSlashCommand<BuiltIn, Plugin, Provider>> = input.builtIn.map(
|
||||
(command) => ({ source: "built-in", command }),
|
||||
);
|
||||
const claimed = new Map<string, "built-in" | "plugin">();
|
||||
for (const command of input.builtIn) {
|
||||
claimed.set(command.name, "built-in");
|
||||
for (const alias of command.aliases ?? []) claimed.set(alias, "built-in");
|
||||
}
|
||||
for (const command of input.plugins) {
|
||||
const winner = claimed.get(command.name);
|
||||
if (winner) {
|
||||
input.onPluginCollision(command, winner);
|
||||
continue;
|
||||
}
|
||||
claimed.set(command.name, "plugin");
|
||||
commands.push({ source: "plugin", command });
|
||||
}
|
||||
for (const command of input.provider) {
|
||||
if (!claimed.has(command.name)) commands.push({ source: "provider", command });
|
||||
}
|
||||
return commands;
|
||||
}
|
||||
|
||||
export function resolvePluginClientSlashCommand<Command extends SlashCommandDescriptor>(input: {
|
||||
text: string;
|
||||
hasAttachments: boolean;
|
||||
commands: readonly Command[];
|
||||
}): { command: Command; args: string } | null {
|
||||
if (input.hasAttachments) return null;
|
||||
const match = /^\/([^\s]+)(?:\s+([\s\S]*))?$/.exec(input.text.trim());
|
||||
if (!match) return null;
|
||||
const command = input.commands.find((candidate) => candidate.name === match[1]);
|
||||
return command ? { command, args: (match[2] ?? "").trim() } : null;
|
||||
}
|
||||
|
||||
export function executePluginClientSlashCommand(input: {
|
||||
command: { run(args: string): Promise<void> };
|
||||
args: string;
|
||||
onError(error: unknown): void;
|
||||
}): void {
|
||||
try {
|
||||
void input.command.run(input.args).catch(input.onError);
|
||||
} catch (error) {
|
||||
input.onError(error);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type PluginAttachmentSourceContribution,
|
||||
type PluginCommandCenterItemContribution,
|
||||
type PluginClientContribution,
|
||||
type PluginClientSlashCommandContribution,
|
||||
type PluginSidebarContribution,
|
||||
type PluginSurfaceProps,
|
||||
type PluginThemeContribution,
|
||||
@@ -73,6 +74,7 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate
|
||||
sidebarItems: [],
|
||||
workspacePanels: [],
|
||||
commandCenterItems: [],
|
||||
clientSlashCommands: [],
|
||||
clientSide: null,
|
||||
attachmentSources: [],
|
||||
themes: [],
|
||||
@@ -83,6 +85,7 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate
|
||||
const sidebarItemIds = new Set<string>();
|
||||
const workspacePanelIds = new Set<string>();
|
||||
const commandCenterItemIds = new Set<string>();
|
||||
const clientSlashCommandNames = new Set<string>();
|
||||
const attachmentSourceIds = new Set<string>();
|
||||
const themeIds = new Set<string>();
|
||||
const timelineTransformerIds = new Set<string>();
|
||||
@@ -166,6 +169,27 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate
|
||||
keywords: contribution.keywords?.map((keyword) => keyword.trim()).filter(Boolean),
|
||||
});
|
||||
},
|
||||
addClientSlashCommand(contribution: PluginClientSlashCommandContribution) {
|
||||
const name = requireId(contribution.name, "client slash command name");
|
||||
if (clientSlashCommandNames.has(name)) {
|
||||
throw new Error(`Duplicate client slash command: ${name}`);
|
||||
}
|
||||
const description = contribution.description.trim();
|
||||
if (!description) throw new Error(`Client slash command ${name} has no description`);
|
||||
if (contribution.context !== "workspace" && contribution.context !== "agent") {
|
||||
throw new Error(`Client slash command ${name} has invalid context`);
|
||||
}
|
||||
if (typeof contribution.onSubmit !== "function") {
|
||||
throw new Error(`Client slash command ${name} has no callback`);
|
||||
}
|
||||
clientSlashCommandNames.add(name);
|
||||
collector.clientSlashCommands.push({
|
||||
...contribution,
|
||||
name,
|
||||
description,
|
||||
argumentHint: contribution.argumentHint.trim(),
|
||||
});
|
||||
},
|
||||
addClientSide(contribution: PluginClientContribution) {
|
||||
if (collector.clientSide) throw new Error("Plugin has more than one client-side entrypoint");
|
||||
if (typeof contribution !== "function") {
|
||||
@@ -312,6 +336,7 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate
|
||||
sidebarItems: collector.sidebarItems,
|
||||
workspacePanels: collector.workspacePanels as EvaluatedPlugin["workspacePanels"],
|
||||
commandCenterItems: collector.commandCenterItems,
|
||||
clientSlashCommands: collector.clientSlashCommands,
|
||||
clientSide: collector.clientSide,
|
||||
attachmentSources: collector.attachmentSources,
|
||||
themes: collector.themes,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
export { PluginCatalogSync } from "./catalog-sync";
|
||||
export { buildLegacyPluginSurfaceRedirectRoute } from "./routes";
|
||||
export { PluginSidebarItems } from "./sidebar-items";
|
||||
export { PluginSurfaceScreen } from "./surface-screen";
|
||||
export { usePluginAttachmentPicker } from "./attachments/picker";
|
||||
export { PluginResourceAttachmentPill } from "./attachments/pill";
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Icon } from "../icons";
|
||||
import { Modal } from "./modal";
|
||||
import { useToast } from "./toast";
|
||||
import { useRevealedText } from "@/hooks/use-revealed-text";
|
||||
|
||||
export const pluginReactNativeRuntime = { Icon, Modal, useToast };
|
||||
export const pluginReactNativeRuntime = { Icon, Modal, useRevealedText, useToast };
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
import { router, usePathname } from "expo-router";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
|
||||
import { resolvePluginIcon } from "./icons";
|
||||
import { useInstalledPlugins } from "./registry";
|
||||
import { buildPluginSurfaceRoute, hostIdFromPathname } from "./routes";
|
||||
import {
|
||||
getPreferredPluginContributionHost,
|
||||
rememberPluginContributionHost,
|
||||
} from "./contribution-host";
|
||||
import {
|
||||
groupPluginSidebarContributions,
|
||||
type PluginSidebarGroup,
|
||||
type PluginSidebarTarget,
|
||||
} from "./sidebar-groups";
|
||||
import { type PluginSidebarGroup, type PluginSidebarTarget } from "./sidebar-groups";
|
||||
|
||||
function selectTarget(
|
||||
group: PluginSidebarGroup,
|
||||
@@ -25,35 +20,15 @@ function selectTarget(
|
||||
return remembered ?? group.targets[0];
|
||||
}
|
||||
|
||||
export function PluginSidebarItems({ onBeforeNavigate }: { onBeforeNavigate?: () => void }) {
|
||||
const plugins = useInstalledPlugins();
|
||||
const pathname = usePathname();
|
||||
const groups = useMemo(() => groupPluginSidebarContributions(plugins), [plugins]);
|
||||
const currentHostId = hostIdFromPathname(pathname);
|
||||
|
||||
return groups.map((group) => (
|
||||
<PluginSidebarItemRow
|
||||
key={group.key}
|
||||
group={group}
|
||||
currentHostId={currentHostId}
|
||||
pathname={pathname}
|
||||
onBeforeNavigate={onBeforeNavigate}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
function PluginSidebarItemRow({
|
||||
export function PluginSidebarItemRow({
|
||||
group,
|
||||
currentHostId,
|
||||
pathname,
|
||||
onBeforeNavigate,
|
||||
}: {
|
||||
group: PluginSidebarGroup;
|
||||
currentHostId: string | null;
|
||||
pathname: string;
|
||||
onBeforeNavigate?: () => void;
|
||||
}) {
|
||||
const target = selectTarget(group, currentHostId);
|
||||
const pathname = usePathname();
|
||||
const target = selectTarget(group, hostIdFromPathname(pathname));
|
||||
const route = buildPluginSurfaceRoute(target.plugin.serverId, group.pluginId, {
|
||||
kind: "sidebar",
|
||||
id: group.contributionId,
|
||||
|
||||
@@ -7,6 +7,13 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { Text } from "react-native";
|
||||
import { SurfaceErrorBoundary } from "./surface-error-boundary";
|
||||
|
||||
vi.mock("react-native-unistyles", () => ({
|
||||
StyleSheet: {
|
||||
create: (factory: (theme: object) => object) =>
|
||||
factory({ colors: { statusDanger: "red" }, spacing: [0, 4, 8, 12, 16] }),
|
||||
},
|
||||
}));
|
||||
|
||||
let oldSurfaceCleanups = 0;
|
||||
function FailingSurface() {
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { StyleSheet } from "react-native-unistyles";
|
||||
interface SurfaceErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
installation: object;
|
||||
resetKey?: unknown;
|
||||
Surface: unknown;
|
||||
}
|
||||
|
||||
@@ -29,7 +30,9 @@ export class SurfaceErrorBoundary extends Component<
|
||||
componentDidUpdate(previous: SurfaceErrorBoundaryProps): void {
|
||||
if (
|
||||
this.state.error &&
|
||||
(previous.installation !== this.props.installation || previous.Surface !== this.props.Surface)
|
||||
(previous.installation !== this.props.installation ||
|
||||
previous.resetKey !== this.props.resetKey ||
|
||||
previous.Surface !== this.props.Surface)
|
||||
) {
|
||||
this.setState({ error: null });
|
||||
}
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types";
|
||||
import { pluginRegistry } from "../registry";
|
||||
import {
|
||||
transformTimelineItem,
|
||||
type InstalledPluginTimelineItem,
|
||||
type TimelineItemTransform,
|
||||
} from "./model";
|
||||
import { useMemo } from "react";
|
||||
import { useInstalledPlugins } from "../registry";
|
||||
import { transformTimelineItem, type TimelineItemTransform } from "./model";
|
||||
|
||||
export type { InstalledPluginTimelineItem, TimelineItemTransform } from "./model";
|
||||
export { PluginTimelineItemView } from "./view";
|
||||
|
||||
export function createInstalledTimelineTransform(serverId: string): TimelineItemTransform {
|
||||
return (item: AgentTimelineItem): InstalledPluginTimelineItem[] | undefined => {
|
||||
const plugins = pluginRegistry.getSnapshot().filter((plugin) => plugin.serverId === serverId);
|
||||
return transformTimelineItem(item, plugins);
|
||||
};
|
||||
export function useInstalledTimelineTransform(serverId: string): TimelineItemTransform {
|
||||
const installed = useInstalledPlugins();
|
||||
const plugins = useMemo(
|
||||
() => installed.filter((plugin) => plugin.serverId === serverId),
|
||||
[installed, serverId],
|
||||
);
|
||||
return useMemo(() => (input) => transformTimelineItem({ ...input, plugins }), [plugins]);
|
||||
}
|
||||
|
||||
@@ -44,26 +44,32 @@ const toolCall = {
|
||||
};
|
||||
|
||||
describe("plugin timeline transforms", () => {
|
||||
it("adds the installation identity to plain transformed items", () => {
|
||||
const transformed = transformTimelineItem(toolCall, [
|
||||
plugin({
|
||||
id: "reports",
|
||||
transform: () => ({
|
||||
items: [
|
||||
{
|
||||
type: "plugin" as const,
|
||||
kind: "test-report",
|
||||
version: 1,
|
||||
data: { passed: 4 },
|
||||
},
|
||||
],
|
||||
it("adds installation and source identity to plain transformed items", () => {
|
||||
const transformed = transformTimelineItem({
|
||||
item: toolCall,
|
||||
phase: "complete",
|
||||
sourceId: "agent_tool_call-1",
|
||||
plugins: [
|
||||
plugin({
|
||||
id: "reports",
|
||||
transform: () => ({
|
||||
items: [
|
||||
{
|
||||
type: "plugin" as const,
|
||||
kind: "test-report",
|
||||
version: 1,
|
||||
data: { passed: 4 },
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
],
|
||||
});
|
||||
|
||||
expect(transformed).toEqual([
|
||||
{
|
||||
type: "plugin",
|
||||
id: "agent_tool_call-1/0",
|
||||
pluginId: "reports",
|
||||
kind: "test-report",
|
||||
version: 1,
|
||||
@@ -74,16 +80,26 @@ describe("plugin timeline transforms", () => {
|
||||
|
||||
it("keeps the source item when no transformer returns a result", () => {
|
||||
expect(
|
||||
transformTimelineItem(toolCall, [plugin({ id: "reports", transform: () => undefined })]),
|
||||
transformTimelineItem({
|
||||
item: toolCall,
|
||||
phase: "complete",
|
||||
sourceId: "agent_tool_call-1",
|
||||
plugins: [plugin({ id: "reports", transform: () => undefined })],
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("accepts an empty replacement and stops after the first result", () => {
|
||||
const second = vi.fn(() => ({ items: [] }));
|
||||
const transformed = transformTimelineItem(toolCall, [
|
||||
plugin({ id: "first", transform: () => ({ items: [] }) }),
|
||||
plugin({ id: "second", transform: second }),
|
||||
]);
|
||||
const transformed = transformTimelineItem({
|
||||
item: toolCall,
|
||||
phase: "complete",
|
||||
sourceId: "agent_tool_call-1",
|
||||
plugins: [
|
||||
plugin({ id: "first", transform: () => ({ items: [] }) }),
|
||||
plugin({ id: "second", transform: second }),
|
||||
],
|
||||
});
|
||||
|
||||
expect(transformed).toEqual([]);
|
||||
expect(second).not.toHaveBeenCalled();
|
||||
@@ -91,21 +107,26 @@ describe("plugin timeline transforms", () => {
|
||||
|
||||
it("rejects non-JSON data without breaking the timeline", () => {
|
||||
const warning = vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
const transformed = transformTimelineItem(toolCall, [
|
||||
plugin({
|
||||
id: "broken",
|
||||
transform: () => ({
|
||||
items: [
|
||||
{
|
||||
type: "plugin" as const,
|
||||
kind: "bad-data",
|
||||
version: 1,
|
||||
data: { value: Number.NaN },
|
||||
},
|
||||
],
|
||||
const transformed = transformTimelineItem({
|
||||
item: toolCall,
|
||||
phase: "complete",
|
||||
sourceId: "agent_tool_call-1",
|
||||
plugins: [
|
||||
plugin({
|
||||
id: "broken",
|
||||
transform: () => ({
|
||||
items: [
|
||||
{
|
||||
type: "plugin" as const,
|
||||
kind: "bad-data",
|
||||
version: 1,
|
||||
data: { value: Number.NaN },
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
],
|
||||
});
|
||||
|
||||
expect(transformed).toBeUndefined();
|
||||
expect(warning).toHaveBeenCalledOnce();
|
||||
@@ -114,21 +135,26 @@ describe("plugin timeline transforms", () => {
|
||||
|
||||
it("rejects class instances disguised as JSON data", () => {
|
||||
const warning = vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
const transformed = transformTimelineItem(toolCall, [
|
||||
plugin({
|
||||
id: "broken",
|
||||
transform: () => ({
|
||||
items: [
|
||||
{
|
||||
type: "plugin" as const,
|
||||
kind: "bad-data",
|
||||
version: 1,
|
||||
data: { createdAt: new Date() } as never,
|
||||
},
|
||||
],
|
||||
const transformed = transformTimelineItem({
|
||||
item: toolCall,
|
||||
phase: "complete",
|
||||
sourceId: "agent_tool_call-1",
|
||||
plugins: [
|
||||
plugin({
|
||||
id: "broken",
|
||||
transform: () => ({
|
||||
items: [
|
||||
{
|
||||
type: "plugin" as const,
|
||||
kind: "bad-data",
|
||||
version: 1,
|
||||
data: { createdAt: new Date() } as never,
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
],
|
||||
});
|
||||
|
||||
expect(transformed).toBeUndefined();
|
||||
expect(warning).toHaveBeenCalledOnce();
|
||||
@@ -137,16 +163,48 @@ describe("plugin timeline transforms", () => {
|
||||
|
||||
it("takes a JSON snapshot of transformed data", () => {
|
||||
const data = { passed: 4 };
|
||||
const transformed = transformTimelineItem(toolCall, [
|
||||
plugin({
|
||||
id: "reports",
|
||||
transform: () => ({
|
||||
items: [{ type: "plugin" as const, kind: "test-report", version: 1, data }],
|
||||
const transformed = transformTimelineItem({
|
||||
item: toolCall,
|
||||
phase: "complete",
|
||||
sourceId: "agent_tool_call-1",
|
||||
plugins: [
|
||||
plugin({
|
||||
id: "reports",
|
||||
transform: () => ({
|
||||
items: [{ type: "plugin" as const, kind: "test-report", version: 1, data }],
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
],
|
||||
});
|
||||
|
||||
data.passed = 0;
|
||||
expect(transformed?.[0]?.data).toEqual({ passed: 4 });
|
||||
});
|
||||
|
||||
it("passes phase and preserves explicit output identity", () => {
|
||||
const transform = vi.fn(() => ({
|
||||
items: [
|
||||
{
|
||||
type: "plugin" as const,
|
||||
id: "summary",
|
||||
kind: "test-report",
|
||||
version: 1,
|
||||
data: { passed: 4 },
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
const transformed = transformTimelineItem({
|
||||
item: { ...toolCall, status: "running" },
|
||||
phase: "streaming",
|
||||
sourceId: "agent_tool_call-1",
|
||||
plugins: [plugin({ id: "reports", transform })],
|
||||
});
|
||||
|
||||
expect(transform).toHaveBeenCalledWith({
|
||||
item: { ...toolCall, status: "running" },
|
||||
phase: "streaming",
|
||||
});
|
||||
expect(transformed?.[0]?.id).toBe("summary");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,11 +7,18 @@ import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types";
|
||||
import type { InstalledPlugin } from "../types";
|
||||
|
||||
export interface InstalledPluginTimelineItem extends PluginTimelineItem {
|
||||
id: string;
|
||||
pluginId: string;
|
||||
}
|
||||
|
||||
export interface TimelineItemTransformInput {
|
||||
item: AgentTimelineItem;
|
||||
phase: "streaming" | "complete";
|
||||
sourceId: string;
|
||||
}
|
||||
|
||||
export type TimelineItemTransform = (
|
||||
item: AgentTimelineItem,
|
||||
input: TimelineItemTransformInput,
|
||||
) => InstalledPluginTimelineItem[] | undefined;
|
||||
|
||||
function isTimelineData(value: unknown, ancestors: Set<object>): value is PluginTimelineData {
|
||||
@@ -46,10 +53,14 @@ function parseTransformResult(value: unknown): PluginTimelineTransformResult {
|
||||
throw new Error('transformed timeline items must have type "plugin"');
|
||||
}
|
||||
const kind = Reflect.get(item, "kind");
|
||||
const id = Reflect.get(item, "id");
|
||||
const version = Reflect.get(item, "version");
|
||||
if (typeof kind !== "string" || !/^[a-z][a-z0-9-]*$/.test(kind)) {
|
||||
throw new Error(`invalid transformed timeline item kind: ${String(kind)}`);
|
||||
}
|
||||
if (id !== undefined && (typeof id !== "string" || id.length === 0)) {
|
||||
throw new Error(`invalid transformed timeline item id: ${String(id)}`);
|
||||
}
|
||||
if (!Number.isInteger(version) || Number(version) < 1) {
|
||||
throw new Error(`invalid transformed timeline item version: ${String(version)}`);
|
||||
}
|
||||
@@ -61,21 +72,22 @@ function parseTransformResult(value: unknown): PluginTimelineTransformResult {
|
||||
}
|
||||
|
||||
export function transformTimelineItem(
|
||||
item: AgentTimelineItem,
|
||||
plugins: readonly InstalledPlugin[],
|
||||
input: TimelineItemTransformInput & { plugins: readonly InstalledPlugin[] },
|
||||
): InstalledPluginTimelineItem[] | undefined {
|
||||
for (const plugin of plugins) {
|
||||
for (const plugin of input.plugins) {
|
||||
for (const transformer of plugin.timelineTransformers) {
|
||||
if (transformer.query.itemType !== item.type) continue;
|
||||
if (transformer.query.itemType !== input.item.type) continue;
|
||||
try {
|
||||
const transform = transformer.transform as (input: {
|
||||
item: AgentTimelineItem;
|
||||
phase: "streaming" | "complete";
|
||||
}) => PluginTimelineTransformResult | undefined;
|
||||
const output = transform({ item });
|
||||
const output = transform({ item: input.item, phase: input.phase });
|
||||
if (output === undefined) continue;
|
||||
const parsed = parseTransformResult(output);
|
||||
return parsed.items.map((transformedItem) => ({
|
||||
return parsed.items.map((transformedItem, index) => ({
|
||||
type: "plugin",
|
||||
id: transformedItem.id ?? `${input.sourceId}/${index}`,
|
||||
kind: transformedItem.kind,
|
||||
version: transformedItem.version,
|
||||
data: JSON.parse(JSON.stringify(transformedItem.data)) as PluginTimelineData,
|
||||
|
||||
@@ -1,101 +1,112 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type { TimelineItemTransform } from "./model";
|
||||
import {
|
||||
processAgentStreamEvent,
|
||||
processTimelineResponse,
|
||||
} from "@/timeline/session-stream-reducers";
|
||||
import { projectPluginTimelineItems } from "./projection";
|
||||
|
||||
const transform: TimelineItemTransform = (item) => {
|
||||
if (item.type !== "tool_call" || item.status === "running") return;
|
||||
return [
|
||||
{
|
||||
type: "plugin",
|
||||
pluginId: "reports",
|
||||
kind: "test-report",
|
||||
version: 1,
|
||||
data: { name: item.name },
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const event: AgentStreamEventPayload = {
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
item: {
|
||||
type: "tool_call",
|
||||
callId: "call-1",
|
||||
name: "tests",
|
||||
detail: { type: "unknown", input: null, output: null },
|
||||
status: "completed",
|
||||
error: null,
|
||||
},
|
||||
};
|
||||
function thought(text: string, status: "loading" | "ready" = "loading"): StreamItem {
|
||||
return {
|
||||
kind: "thought",
|
||||
id: "thought-1",
|
||||
text,
|
||||
status,
|
||||
timestamp: new Date("2026-01-01T00:00:00.000Z"),
|
||||
};
|
||||
}
|
||||
|
||||
describe("plugin timeline projection", () => {
|
||||
it("applies the same transform to fetched projected history", () => {
|
||||
const result = processTimelineResponse({
|
||||
it("does not expose mutable tool detail from stream state", () => {
|
||||
const detail = { type: "read" as const, filePath: "/repo/original.ts" };
|
||||
const source: StreamItem = {
|
||||
kind: "tool_call",
|
||||
id: "tool-1",
|
||||
timestamp: new Date("2026-01-01T00:00:00.000Z"),
|
||||
payload: {
|
||||
agentId: "agent-1",
|
||||
direction: "tail",
|
||||
projection: "projected",
|
||||
reset: true,
|
||||
epoch: "epoch-1",
|
||||
window: { minSeq: 1, maxSeq: 1, nextSeq: 2 },
|
||||
startCursor: { seq: 1 },
|
||||
endCursor: { seq: 1 },
|
||||
entries: [
|
||||
{
|
||||
seqStart: 1,
|
||||
seqEnd: 1,
|
||||
provider: "claude",
|
||||
item: event.item,
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
hasNewer: false,
|
||||
hasOlder: false,
|
||||
source: "agent",
|
||||
data: {
|
||||
provider: "claude",
|
||||
callId: "call-1",
|
||||
name: "Read",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail,
|
||||
},
|
||||
},
|
||||
currentTail: [],
|
||||
currentHead: [],
|
||||
currentCursor: undefined,
|
||||
isInitializing: true,
|
||||
hasActiveInitDeferred: true,
|
||||
initRequestDirection: "tail",
|
||||
sendingClientMessageIds: [],
|
||||
transformTimelineItem: transform,
|
||||
});
|
||||
};
|
||||
const mutateDetail: TimelineItemTransform = ({ item }) => {
|
||||
if (item.type === "tool_call" && item.detail.type === "read") {
|
||||
item.detail.filePath = "/repo/mutated.ts";
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
expect(result.tail).toMatchObject([
|
||||
expect(() => projectPluginTimelineItems([source], mutateDetail)).toThrow(TypeError);
|
||||
expect(detail.filePath).toBe("/repo/original.ts");
|
||||
});
|
||||
|
||||
it("projects a streaming reasoning row from its first delta", () => {
|
||||
const transform: TimelineItemTransform = vi.fn(({ item, phase, sourceId }) => [
|
||||
{
|
||||
type: "plugin" as const,
|
||||
id: `${sourceId}/0`,
|
||||
pluginId: "inline-thinking",
|
||||
kind: "reasoning",
|
||||
version: 1,
|
||||
data: { text: item.type === "reasoning" ? item.text : "", phase },
|
||||
},
|
||||
]);
|
||||
|
||||
expect(projectPluginTimelineItems([thought("First")], transform)).toMatchObject([
|
||||
{
|
||||
kind: "plugin",
|
||||
pluginId: "reports",
|
||||
itemKind: "test-report",
|
||||
data: { name: "tests" },
|
||||
timelineCursor: { epoch: "epoch-1", seq: 1 },
|
||||
id: "inline-thinking/thought-1/0",
|
||||
data: { text: "First", phase: "streaming" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("requests authoritative projection when a live delta matches", () => {
|
||||
const result = processAgentStreamEvent({
|
||||
event,
|
||||
seq: 1,
|
||||
epoch: "epoch-1",
|
||||
currentTail: [],
|
||||
currentHead: [],
|
||||
currentCursor: undefined,
|
||||
timestamp: new Date("2026-01-01T00:00:00.000Z"),
|
||||
transformTimelineItem: transform,
|
||||
});
|
||||
it("memoizes projection on the source row reference", () => {
|
||||
const source = thought("Stable");
|
||||
const transform: TimelineItemTransform = vi.fn(() => undefined);
|
||||
projectPluginTimelineItems([source], transform);
|
||||
projectPluginTimelineItems([source], transform);
|
||||
|
||||
expect(result.tail).toMatchObject([
|
||||
expect(transform).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("reprojects when streaming produces a new source row", () => {
|
||||
const transform: TimelineItemTransform = vi.fn(() => undefined);
|
||||
projectPluginTimelineItems([thought("First")], transform);
|
||||
projectPluginTimelineItems([thought("First second")], transform);
|
||||
|
||||
expect(transform).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("filters and explodes source rows", () => {
|
||||
const source = thought("Split", "ready");
|
||||
const exploded = projectPluginTimelineItems([source], ({ sourceId }) => [
|
||||
{
|
||||
kind: "tool_call",
|
||||
timelineCursor: { epoch: "epoch-1", seq: 1 },
|
||||
type: "plugin",
|
||||
id: `${sourceId}/left`,
|
||||
pluginId: "split",
|
||||
kind: "part",
|
||||
version: 1,
|
||||
data: { side: "left" },
|
||||
},
|
||||
{
|
||||
type: "plugin",
|
||||
id: `${sourceId}/right`,
|
||||
pluginId: "split",
|
||||
kind: "part",
|
||||
version: 1,
|
||||
data: { side: "right" },
|
||||
},
|
||||
]);
|
||||
expect(result.sideEffects).toContainEqual({ type: "reproject" });
|
||||
const filtered = projectPluginTimelineItems([source], () => []);
|
||||
|
||||
expect(exploded.map((item) => item.id)).toEqual([
|
||||
"split/thought-1/left",
|
||||
"split/thought-1/right",
|
||||
]);
|
||||
expect(filtered).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { AgentTimelineItem, ToolCallTimelineItem } from "@getpaseo/protocol/agent-types";
|
||||
import type { AgentToolCallData, PluginTimelineStreamItem, StreamItem } from "@/types/stream";
|
||||
import type { TimelineItemTransform } from "./model";
|
||||
|
||||
const projectionCache = new WeakMap<TimelineItemTransform, WeakMap<StreamItem, StreamItem[]>>();
|
||||
|
||||
function cloneAndFreeze<T>(value: T): T {
|
||||
if (Array.isArray(value)) {
|
||||
return Object.freeze(value.map((entry) => cloneAndFreeze(entry))) as T;
|
||||
}
|
||||
if (value !== null && typeof value === "object") {
|
||||
return Object.freeze(
|
||||
Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, cloneAndFreeze(entry)])),
|
||||
) as T;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function sourceToolCallTimelineItem(data: AgentToolCallData): ToolCallTimelineItem {
|
||||
const { callId, name, status, error, detail, metadata } = data;
|
||||
const base = {
|
||||
type: "tool_call" as const,
|
||||
callId,
|
||||
name,
|
||||
detail,
|
||||
...(metadata ? { metadata } : {}),
|
||||
};
|
||||
switch (status) {
|
||||
case "running":
|
||||
case "completed":
|
||||
case "canceled":
|
||||
return { ...base, status, error: null };
|
||||
case "failed":
|
||||
return { ...base, status, error };
|
||||
}
|
||||
}
|
||||
|
||||
function sourceTimelineItem(item: StreamItem): AgentTimelineItem | null {
|
||||
switch (item.kind) {
|
||||
case "user_message":
|
||||
return {
|
||||
type: "user_message",
|
||||
text: item.text,
|
||||
...(item.messageId ? { messageId: item.messageId } : {}),
|
||||
...(item.clientMessageId ? { clientMessageId: item.clientMessageId } : {}),
|
||||
};
|
||||
case "assistant_message":
|
||||
return {
|
||||
type: "assistant_message",
|
||||
text: item.text,
|
||||
...(item.messageId ? { messageId: item.messageId } : {}),
|
||||
};
|
||||
case "thought":
|
||||
return { type: "reasoning", text: item.text };
|
||||
case "tool_call": {
|
||||
if (item.payload.source !== "agent") return null;
|
||||
return sourceToolCallTimelineItem(item.payload.data);
|
||||
}
|
||||
case "todo_list":
|
||||
return { type: "todo", items: item.items };
|
||||
case "activity_log":
|
||||
return item.activityType === "error" ? { type: "error", message: item.message } : null;
|
||||
case "compaction":
|
||||
return {
|
||||
type: "compaction",
|
||||
status: item.status,
|
||||
...(item.trigger ? { trigger: item.trigger } : {}),
|
||||
...(item.preTokens !== undefined ? { preTokens: item.preTokens } : {}),
|
||||
};
|
||||
case "plugin":
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function transformSourceItem(
|
||||
item: StreamItem,
|
||||
transformTimelineItem: TimelineItemTransform,
|
||||
): StreamItem[] {
|
||||
const source = sourceTimelineItem(item);
|
||||
if (!source) return [item];
|
||||
const isStreamingThought = item.kind === "thought" && item.status === "loading";
|
||||
const isStreamingToolCall =
|
||||
item.kind === "tool_call" &&
|
||||
item.payload.source === "agent" &&
|
||||
item.payload.data.status === "running";
|
||||
const phase = isStreamingThought || isStreamingToolCall ? "streaming" : "complete";
|
||||
const transformed = transformTimelineItem({
|
||||
item: cloneAndFreeze(source),
|
||||
phase,
|
||||
sourceId: item.id,
|
||||
});
|
||||
if (transformed === undefined) return [item];
|
||||
return transformed.map((pluginItem) => {
|
||||
const projected: PluginTimelineStreamItem = {
|
||||
kind: "plugin",
|
||||
id: `${pluginItem.pluginId}/${pluginItem.id}`,
|
||||
timestamp: item.timestamp,
|
||||
pluginId: pluginItem.pluginId,
|
||||
pluginItemId: pluginItem.id,
|
||||
itemKind: pluginItem.kind,
|
||||
version: pluginItem.version,
|
||||
data: pluginItem.data,
|
||||
};
|
||||
if (item.timelineCursor) projected.timelineCursor = item.timelineCursor;
|
||||
if (item.turnId) projected.turnId = item.turnId;
|
||||
return projected;
|
||||
});
|
||||
}
|
||||
|
||||
export function projectPluginTimelineItems(
|
||||
items: StreamItem[],
|
||||
transformTimelineItem: TimelineItemTransform | undefined,
|
||||
): StreamItem[] {
|
||||
if (!transformTimelineItem) return items;
|
||||
let bySource = projectionCache.get(transformTimelineItem);
|
||||
if (!bySource) {
|
||||
bySource = new WeakMap();
|
||||
projectionCache.set(transformTimelineItem, bySource);
|
||||
}
|
||||
let changed = false;
|
||||
const projected = items.flatMap((item) => {
|
||||
const cached = bySource.get(item);
|
||||
if (cached) {
|
||||
changed = changed || cached.length !== 1 || cached[0] !== item;
|
||||
return cached;
|
||||
}
|
||||
const output = transformSourceItem(item, transformTimelineItem);
|
||||
bySource.set(item, output);
|
||||
changed = changed || output.length !== 1 || output[0] !== item;
|
||||
return output;
|
||||
});
|
||||
return changed ? projected : items;
|
||||
}
|
||||
@@ -11,6 +11,26 @@ vi.mock("@/runtime/host-runtime", () => ({
|
||||
useHostRuntimeClient: () => ({ invokePluginRpc: async () => null }) as unknown as DaemonClient,
|
||||
useHosts: () => [{ serverId: "host-1", label: "Local" }],
|
||||
}));
|
||||
vi.mock("@/constants/layout", () => ({
|
||||
useIsCompactFormFactor: () => false,
|
||||
}));
|
||||
vi.mock("../navigation", () => ({
|
||||
createPluginNavigation: () => ({}),
|
||||
}));
|
||||
vi.mock("../icons", () => ({
|
||||
Icon: () => null,
|
||||
resolvePluginIcon: () => () => null,
|
||||
}));
|
||||
vi.mock("react-native-unistyles", () => ({
|
||||
StyleSheet: {
|
||||
create: (factory: (theme: object) => object) =>
|
||||
factory({
|
||||
colors: { foregroundMuted: "gray", statusDanger: "red" },
|
||||
spacing: [0, 4, 8, 12, 16],
|
||||
}),
|
||||
},
|
||||
withUnistyles: (Component: React.ComponentType) => Component,
|
||||
}));
|
||||
|
||||
import { pluginRegistry } from "../registry";
|
||||
import { PluginTimelineItemView } from "./view";
|
||||
@@ -44,15 +64,35 @@ const failingBundle = `(function() {
|
||||
} };
|
||||
})`;
|
||||
|
||||
const recoveringBundle = `(function(require) {
|
||||
const React = require("react");
|
||||
return { default: function(plugin) {
|
||||
function RecoveringCard(props) {
|
||||
if (props.item.data.label === "explode") throw new Error("transient renderer failure");
|
||||
return React.createElement("span", null, props.item.data.label);
|
||||
}
|
||||
plugin.addTimelineRenderer({
|
||||
kind: "test-report",
|
||||
version: 1,
|
||||
schema: { safeParse(value) { return { success: true, data: value }; } },
|
||||
Component: RecoveringCard,
|
||||
});
|
||||
return function() {};
|
||||
} };
|
||||
})`;
|
||||
|
||||
const timelineItem = {
|
||||
kind: "plugin" as const,
|
||||
id: "item-1",
|
||||
pluginId: "reports",
|
||||
pluginItemId: "item-1",
|
||||
itemKind: "test-report",
|
||||
version: 1,
|
||||
data: { label: "Four tests passed" },
|
||||
timestamp: new Date("2026-01-01T00:00:00.000Z"),
|
||||
};
|
||||
const failingTimelineItem = { ...timelineItem, data: { label: "explode" } };
|
||||
const recoveredTimelineItem = { ...timelineItem, data: { label: "Recovered" } };
|
||||
|
||||
const roots: Array<ReturnType<typeof createRoot>> = [];
|
||||
const containers: HTMLElement[] = [];
|
||||
@@ -104,4 +144,28 @@ describe("PluginTimelineItemView", () => {
|
||||
expect(container.textContent).toContain("Plugin failed: timeline renderer exploded");
|
||||
expect(container.textContent).toContain("Message after");
|
||||
});
|
||||
|
||||
it("recovers when a streaming item's data changes", async () => {
|
||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
pluginRegistry.installCatalog("host-1", [{ id: "reports", clientBundle: recoveringBundle }]);
|
||||
const container = document.createElement("div");
|
||||
containers.push(container);
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
roots.push(root);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<PluginTimelineItemView serverId="host-1" agentId="agent-1" item={failingTimelineItem} />,
|
||||
);
|
||||
});
|
||||
expect(container.textContent).toBe("Plugin failed: transient renderer failure");
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<PluginTimelineItemView serverId="host-1" agentId="agent-1" item={recoveredTimelineItem} />,
|
||||
);
|
||||
});
|
||||
expect(container.textContent).toBe("Recovered");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -89,7 +89,7 @@ function PluginTimelineItemBody({
|
||||
},
|
||||
};
|
||||
return (
|
||||
<SurfaceErrorBoundary installation={plugin} Surface={Component}>
|
||||
<SurfaceErrorBoundary installation={plugin} resetKey={item.data} Surface={Component}>
|
||||
<PluginRuntimeBoundary plugin={plugin} runtime={runtime}>
|
||||
<PluginClientStateProvider source={stateSource}>
|
||||
<Component {...props} />
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
PluginAttachmentSourceContribution,
|
||||
PluginCommandCenterItemContribution,
|
||||
PluginClientContribution,
|
||||
PluginClientSlashCommandContribution,
|
||||
PluginCleanup,
|
||||
PluginComposerPillContribution,
|
||||
PluginSidebarContribution,
|
||||
@@ -25,6 +26,7 @@ export interface EvaluatedPlugin {
|
||||
sidebarItems: PluginSidebarContribution[];
|
||||
workspacePanels: EvaluatedPluginWorkspacePanelContribution[];
|
||||
commandCenterItems: PluginCommandCenterItemContribution[];
|
||||
clientSlashCommands?: PluginClientSlashCommandContribution[];
|
||||
clientSide: PluginClientContribution | null;
|
||||
attachmentSources: PluginAttachmentSourceContribution[];
|
||||
themes: PluginThemeContribution[];
|
||||
@@ -41,6 +43,7 @@ export interface InstalledPlugin extends EvaluatedPlugin {
|
||||
export type {
|
||||
PluginAttachmentSourceContribution,
|
||||
PluginCommandCenterItemContribution,
|
||||
PluginClientSlashCommandContribution,
|
||||
PluginComposerPillContribution,
|
||||
PluginSidebarContribution,
|
||||
PluginSurfaceContribution,
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
export {
|
||||
buildReviewAttachmentSnapshot,
|
||||
buildReviewDraftKey,
|
||||
buildReviewDraftScopeKey,
|
||||
expireStaleDiffModeOverrides,
|
||||
getReviewDraftComments,
|
||||
resetReviewDraftStore,
|
||||
useClearReviewDraft,
|
||||
useReviewAttachmentSnapshot,
|
||||
useResolvedDiffMode,
|
||||
useSetDiffModeOverride,
|
||||
addReviewDraftComment,
|
||||
type BuildReviewDraftKeyInput,
|
||||
type BuildReviewDraftScopeKeyInput,
|
||||
type DiffModeOverride,
|
||||
type ReviewDraftCommentInput,
|
||||
type ReviewDraftComment,
|
||||
type ReviewDraftMode,
|
||||
|
||||
@@ -13,23 +13,10 @@ export interface ReviewDraftComment {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// A manual mode selection is valid only while the checkout's dirty state matches the
|
||||
// value at the time of selection. serverId/cwd identify the checkout so the override can
|
||||
// be expired when its dirty state changes (see expireStaleDiffModeOverridesInState).
|
||||
export interface DiffModeOverride {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
mode: ReviewDraftMode;
|
||||
isDirtyAtSelection: boolean;
|
||||
}
|
||||
|
||||
export interface ReviewDraftStoreState {
|
||||
drafts: Record<string, ReviewDraftComment[]>;
|
||||
// In-memory only — not persisted. Keyed by scope key.
|
||||
diffModeOverrides: Record<string, DiffModeOverride>;
|
||||
}
|
||||
|
||||
// Only drafts are persisted; diffModeOverrides is intentionally excluded.
|
||||
export interface SerializedReviewDraftState {
|
||||
drafts: Record<string, ReviewDraftComment[]>;
|
||||
activeModesByScope?: Record<string, ReviewDraftMode>;
|
||||
@@ -53,59 +40,6 @@ export const SerializedReviewDraftStateSchema: z.ZodType<SerializedReviewDraftSt
|
||||
activeModesByScope: z.record(z.string(), z.enum(["uncommitted", "base"])).optional(),
|
||||
});
|
||||
|
||||
export function setDiffModeOverrideInState(
|
||||
state: ReviewDraftStoreState,
|
||||
input: { scopeKey: string; override: DiffModeOverride },
|
||||
): ReviewDraftStoreState {
|
||||
return {
|
||||
...state,
|
||||
diffModeOverrides: {
|
||||
...state.diffModeOverrides,
|
||||
[input.scopeKey]: input.override,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Drops every override for the checkout whose dirty state no longer matches the value it
|
||||
// was selected under. Called whenever a checkout status enters the app (push or fetch),
|
||||
// so expiry does not depend on any screen being mounted.
|
||||
export function expireStaleDiffModeOverridesInState(
|
||||
state: ReviewDraftStoreState,
|
||||
input: { serverId: string; cwd: string; isDirty: boolean },
|
||||
): ReviewDraftStoreState {
|
||||
const staleScopeKeys = Object.entries(state.diffModeOverrides)
|
||||
.filter(
|
||||
([, override]) =>
|
||||
override.serverId === input.serverId &&
|
||||
override.cwd === input.cwd &&
|
||||
override.isDirtyAtSelection !== input.isDirty,
|
||||
)
|
||||
.map(([scopeKey]) => scopeKey);
|
||||
if (staleScopeKeys.length === 0) {
|
||||
return state;
|
||||
}
|
||||
const next = { ...state.diffModeOverrides };
|
||||
for (const scopeKey of staleScopeKeys) {
|
||||
delete next[scopeKey];
|
||||
}
|
||||
return { ...state, diffModeOverrides: next };
|
||||
}
|
||||
|
||||
// Pure read — returns the effective mode without mutating state. The staleness check is
|
||||
// kept even though stale overrides are expired at the data boundary: a render can observe
|
||||
// a fresh dirty state before the expiry lands, and resolution must be correct under any
|
||||
// interleaving.
|
||||
export function resolveDiffMode(input: {
|
||||
override: DiffModeOverride | undefined;
|
||||
hasUncommittedChanges: boolean;
|
||||
}): ReviewDraftMode {
|
||||
const { override, hasUncommittedChanges } = input;
|
||||
if (override && override.isDirtyAtSelection === hasUncommittedChanges) {
|
||||
return override.mode;
|
||||
}
|
||||
return hasUncommittedChanges ? "uncommitted" : "base";
|
||||
}
|
||||
|
||||
export function addCommentToState(
|
||||
state: ReviewDraftStoreState,
|
||||
input: { key: string; comment: ReviewDraftComment },
|
||||
@@ -184,7 +118,6 @@ export function normalizePersistedState(state: unknown): ReviewDraftStoreState {
|
||||
const result = SerializedReviewDraftStateSchema.safeParse(state);
|
||||
return {
|
||||
drafts: result.success ? result.data.drafts : {},
|
||||
diffModeOverrides: {},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2,35 +2,21 @@ import { describe, expect, it } from "vitest";
|
||||
import type { StateStorage } from "zustand/middleware";
|
||||
import type { ParsedDiffFile } from "@/git/use-diff-query";
|
||||
import { createValidatedPersistStorage } from "@/storage/validated-persist-storage";
|
||||
import {
|
||||
buildReviewAttachmentSnapshot,
|
||||
buildReviewDraftKey,
|
||||
buildReviewDraftScopeKey,
|
||||
} from "./store";
|
||||
import { buildReviewAttachmentSnapshot, buildReviewDraftKey } from "./store";
|
||||
import {
|
||||
addCommentToState,
|
||||
clearReviewInState,
|
||||
deleteCommentFromState,
|
||||
type DiffModeOverride,
|
||||
expireStaleDiffModeOverridesInState,
|
||||
normalizePersistedState,
|
||||
resolveDiffMode,
|
||||
type ReviewDraftComment,
|
||||
type ReviewDraftStoreState,
|
||||
serializeReviewDraftState,
|
||||
SerializedReviewDraftStateSchema,
|
||||
setDiffModeOverrideInState,
|
||||
updateCommentInState,
|
||||
} from "./state";
|
||||
|
||||
function emptyState(): ReviewDraftStoreState {
|
||||
return { drafts: {}, diffModeOverrides: {} };
|
||||
}
|
||||
|
||||
function makeOverride(
|
||||
input: Pick<DiffModeOverride, "mode" | "isDirtyAtSelection">,
|
||||
): DiffModeOverride {
|
||||
return { serverId: "server-1", cwd: "/repo", ...input };
|
||||
return { drafts: {} };
|
||||
}
|
||||
|
||||
function makeComment(overrides: Partial<ReviewDraftComment> = {}): ReviewDraftComment {
|
||||
@@ -121,21 +107,6 @@ describe("buildReviewDraftKey", () => {
|
||||
}),
|
||||
).toBe("review:server=local:cwd=%2Frepo:mode=base:base=main:ignoreWhitespace=false");
|
||||
});
|
||||
|
||||
it("builds a mode-free scope key for diff mode override sharing", () => {
|
||||
const scope = buildReviewDraftScopeKey({
|
||||
serverId: "local",
|
||||
workspaceId: "workspace-1",
|
||||
cwd: "/repo",
|
||||
baseRef: "main",
|
||||
ignoreWhitespace: false,
|
||||
});
|
||||
|
||||
expect(scope).toBe(
|
||||
"review:server=local:workspace=workspace-1:base=main:ignoreWhitespace=false",
|
||||
);
|
||||
expect(scope).not.toContain("mode=");
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizePersistedState", () => {
|
||||
@@ -155,7 +126,6 @@ describe("normalizePersistedState", () => {
|
||||
const normalized = normalizePersistedState(stored?.state);
|
||||
|
||||
expect(normalized.drafts["review:key"]).toEqual([makeComment()]);
|
||||
expect(normalized.diffModeOverrides).toEqual({});
|
||||
expect(backing.values.has("@paseo:review-draft-store")).toBe(true);
|
||||
});
|
||||
|
||||
@@ -182,149 +152,28 @@ describe("normalizePersistedState", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(normalized.diffModeOverrides).toEqual({});
|
||||
expect(normalized.drafts).toEqual({});
|
||||
});
|
||||
|
||||
it("returns empty state for null, non-object, or malformed inputs", () => {
|
||||
expect(normalizePersistedState(null)).toEqual({ drafts: {}, diffModeOverrides: {} });
|
||||
expect(normalizePersistedState("nope")).toEqual({ drafts: {}, diffModeOverrides: {} });
|
||||
expect(normalizePersistedState({ drafts: [] })).toEqual({
|
||||
drafts: {},
|
||||
diffModeOverrides: {},
|
||||
});
|
||||
expect(normalizePersistedState(null)).toEqual({ drafts: {} });
|
||||
expect(normalizePersistedState("nope")).toEqual({ drafts: {} });
|
||||
expect(normalizePersistedState({ drafts: [] })).toEqual({ drafts: {} });
|
||||
});
|
||||
});
|
||||
|
||||
describe("serializeReviewDraftState", () => {
|
||||
it("serialized output does not contain activeModesByScope or diffModeOverrides", () => {
|
||||
const state = setDiffModeOverrideInState(
|
||||
addCommentToState(emptyState(), { key: "review:key", comment: makeComment() }),
|
||||
{
|
||||
scopeKey: "review:scope",
|
||||
override: makeOverride({ mode: "uncommitted", isDirtyAtSelection: true }),
|
||||
},
|
||||
);
|
||||
it("serialized output does not contain the legacy activeModesByScope field", () => {
|
||||
const state = addCommentToState(emptyState(), { key: "review:key", comment: makeComment() });
|
||||
|
||||
const serialized = serializeReviewDraftState(state);
|
||||
|
||||
expect(Object.keys(serialized)).toEqual(["drafts"]);
|
||||
expect("activeModesByScope" in serialized).toBe(false);
|
||||
expect("diffModeOverrides" in serialized).toBe(false);
|
||||
expect(serialized.drafts["review:key"]).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("diff mode override", () => {
|
||||
it("resolves to auto mode from the dirty state when no override exists", () => {
|
||||
expect(resolveDiffMode({ override: undefined, hasUncommittedChanges: true })).toBe(
|
||||
"uncommitted",
|
||||
);
|
||||
expect(resolveDiffMode({ override: undefined, hasUncommittedChanges: false })).toBe("base");
|
||||
});
|
||||
|
||||
it("honors the override while isDirty matches the value at selection, across remounts", () => {
|
||||
const state = setDiffModeOverrideInState(emptyState(), {
|
||||
scopeKey: "review:scope",
|
||||
override: makeOverride({ mode: "base", isDirtyAtSelection: true }),
|
||||
});
|
||||
|
||||
// Resolution is a plain store read, so it gives the same answer on every (re)mount.
|
||||
const override = state.diffModeOverrides["review:scope"];
|
||||
expect(resolveDiffMode({ override, hasUncommittedChanges: true })).toBe("base");
|
||||
});
|
||||
|
||||
it("masks a stale override before its expiry lands", () => {
|
||||
const state = setDiffModeOverrideInState(emptyState(), {
|
||||
scopeKey: "review:scope",
|
||||
override: makeOverride({ mode: "uncommitted", isDirtyAtSelection: true }),
|
||||
});
|
||||
|
||||
const override = state.diffModeOverrides["review:scope"];
|
||||
expect(resolveDiffMode({ override, hasUncommittedChanges: false })).toBe("base");
|
||||
});
|
||||
|
||||
it("expires overrides for the checkout whose dirty state flipped, keeping the rest", () => {
|
||||
let state = setDiffModeOverrideInState(emptyState(), {
|
||||
scopeKey: "review:scope-a",
|
||||
override: makeOverride({ mode: "base", isDirtyAtSelection: true }),
|
||||
});
|
||||
state = setDiffModeOverrideInState(state, {
|
||||
scopeKey: "review:scope-b",
|
||||
override: makeOverride({ mode: "uncommitted", isDirtyAtSelection: false }),
|
||||
});
|
||||
state = setDiffModeOverrideInState(state, {
|
||||
scopeKey: "review:scope-other",
|
||||
override: {
|
||||
serverId: "server-2",
|
||||
cwd: "/other",
|
||||
mode: "base",
|
||||
isDirtyAtSelection: true,
|
||||
},
|
||||
});
|
||||
|
||||
const next = expireStaleDiffModeOverridesInState(state, {
|
||||
serverId: "server-1",
|
||||
cwd: "/repo",
|
||||
isDirty: false,
|
||||
});
|
||||
|
||||
expect(next.diffModeOverrides["review:scope-a"]).toBeUndefined();
|
||||
expect(next.diffModeOverrides["review:scope-b"]).toBeDefined();
|
||||
expect(next.diffModeOverrides["review:scope-other"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not resurrect an expired override when the dirty state flips back", () => {
|
||||
let state = setDiffModeOverrideInState(emptyState(), {
|
||||
scopeKey: "review:scope",
|
||||
override: makeOverride({ mode: "base", isDirtyAtSelection: true }),
|
||||
});
|
||||
|
||||
// The checkout goes clean (e.g. agent commits): the override expires at the boundary.
|
||||
state = expireStaleDiffModeOverridesInState(state, {
|
||||
serverId: "server-1",
|
||||
cwd: "/repo",
|
||||
isDirty: false,
|
||||
});
|
||||
// The checkout goes dirty again: nothing to expire, and nothing comes back.
|
||||
state = expireStaleDiffModeOverridesInState(state, {
|
||||
serverId: "server-1",
|
||||
cwd: "/repo",
|
||||
isDirty: true,
|
||||
});
|
||||
|
||||
expect(state.diffModeOverrides["review:scope"]).toBeUndefined();
|
||||
expect(
|
||||
resolveDiffMode({
|
||||
override: state.diffModeOverrides["review:scope"],
|
||||
hasUncommittedChanges: true,
|
||||
}),
|
||||
).toBe("uncommitted");
|
||||
});
|
||||
|
||||
it("keeps state identity when no override is stale", () => {
|
||||
const state = setDiffModeOverrideInState(emptyState(), {
|
||||
scopeKey: "review:scope",
|
||||
override: makeOverride({ mode: "base", isDirtyAtSelection: true }),
|
||||
});
|
||||
|
||||
expect(
|
||||
expireStaleDiffModeOverridesInState(state, {
|
||||
serverId: "server-1",
|
||||
cwd: "/repo",
|
||||
isDirty: true,
|
||||
}),
|
||||
).toBe(state);
|
||||
expect(
|
||||
expireStaleDiffModeOverridesInState(emptyState(), {
|
||||
serverId: "server-1",
|
||||
cwd: "/repo",
|
||||
isDirty: false,
|
||||
}),
|
||||
).toEqual(emptyState());
|
||||
});
|
||||
});
|
||||
|
||||
describe("review draft reducers", () => {
|
||||
it("adds, updates, and deletes draft comments by key", () => {
|
||||
let state = emptyState();
|
||||
|
||||
@@ -8,17 +8,13 @@ import {
|
||||
addCommentToState,
|
||||
clearReviewInState,
|
||||
deleteCommentFromState,
|
||||
type DiffModeOverride,
|
||||
expireStaleDiffModeOverridesInState,
|
||||
normalizePersistedState,
|
||||
resolveDiffMode,
|
||||
type ReviewDraftComment,
|
||||
type ReviewDraftMode,
|
||||
type ReviewDraftSide,
|
||||
type ReviewDraftStoreState,
|
||||
serializeReviewDraftState,
|
||||
SerializedReviewDraftStateSchema,
|
||||
setDiffModeOverrideInState,
|
||||
updateCommentInState,
|
||||
} from "@/review/state";
|
||||
import { generateMessageId } from "@/types/stream";
|
||||
@@ -26,14 +22,9 @@ import { buildNumberedDiffHunks, type NumberedDiffLine } from "@/utils/diff-layo
|
||||
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||||
import { createValidatedPersistStorage } from "@/storage/validated-persist-storage";
|
||||
|
||||
export type {
|
||||
DiffModeOverride,
|
||||
ReviewDraftComment,
|
||||
ReviewDraftMode,
|
||||
ReviewDraftSide,
|
||||
} from "@/review/state";
|
||||
export type { ReviewDraftComment, ReviewDraftMode, ReviewDraftSide } from "@/review/state";
|
||||
|
||||
// v2 dropped persisted activeModesByScope (diff mode overrides are in-memory only).
|
||||
// v2 dropped the legacy persisted activeModesByScope field.
|
||||
const STORE_VERSION = 2;
|
||||
const CONTEXT_RADIUS = 3;
|
||||
const EMPTY_REVIEW_DRAFT_COMMENTS: ReviewDraftComment[] = [];
|
||||
@@ -51,7 +42,7 @@ export interface BuildReviewDraftKeyInput {
|
||||
ignoreWhitespace: boolean;
|
||||
}
|
||||
|
||||
export type BuildReviewDraftScopeKeyInput = Omit<BuildReviewDraftKeyInput, "mode">;
|
||||
type BuildReviewDraftScopeKeyInput = Omit<BuildReviewDraftKeyInput, "mode">;
|
||||
|
||||
export interface BuildReviewAttachmentSnapshotInput {
|
||||
reviewDraftKey: string;
|
||||
@@ -66,7 +57,6 @@ export type ReviewDraftCommentInput = Omit<ReviewDraftComment, "id" | "createdAt
|
||||
Partial<Pick<ReviewDraftComment, "id" | "createdAt" | "updatedAt">>;
|
||||
|
||||
interface ReviewDraftStoreActions {
|
||||
setDiffModeOverride: (input: { scopeKey: string; override: DiffModeOverride }) => void;
|
||||
addComment: (input: { key: string; comment: ReviewDraftCommentInput }) => ReviewDraftComment;
|
||||
updateComment: (input: {
|
||||
key: string;
|
||||
@@ -112,10 +102,6 @@ function buildReviewDraftScopeParts(input: BuildReviewDraftScopeKeyInput): strin
|
||||
];
|
||||
}
|
||||
|
||||
export function buildReviewDraftScopeKey(input: BuildReviewDraftScopeKeyInput): string {
|
||||
return buildReviewDraftScopeParts(input).join(":");
|
||||
}
|
||||
|
||||
export function buildReviewDraftKey(input: BuildReviewDraftKeyInput): string {
|
||||
const [prefix, serverPart, workspacePart, basePart, whitespacePart] =
|
||||
buildReviewDraftScopeParts(input);
|
||||
@@ -141,10 +127,6 @@ export const useReviewDraftStore = create<ReviewDraftStore>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
drafts: {},
|
||||
diffModeOverrides: {},
|
||||
setDiffModeOverride: (input) => {
|
||||
set((state) => setDiffModeOverrideInState(state, input));
|
||||
},
|
||||
addComment: ({ key, comment }) => {
|
||||
const nextComment = createDraftComment(comment);
|
||||
set((state) => addCommentToState(state, { key, comment: nextComment }));
|
||||
@@ -283,20 +265,6 @@ export function useReviewDraftComments(key: string): ReviewDraftComment[] {
|
||||
return useReviewDraftStore((state) => state.drafts[key] ?? EMPTY_REVIEW_DRAFT_COMMENTS);
|
||||
}
|
||||
|
||||
export function useSetDiffModeOverride(): ReviewDraftStoreActions["setDiffModeOverride"] {
|
||||
return useReviewDraftStore((state) => state.setDiffModeOverride);
|
||||
}
|
||||
|
||||
// Non-React entry point: called from the checkout-status data boundary, where dirty-state
|
||||
// changes enter the app regardless of which screens are mounted.
|
||||
export function expireStaleDiffModeOverrides(input: {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
isDirty: boolean;
|
||||
}): void {
|
||||
useReviewDraftStore.setState((state) => expireStaleDiffModeOverridesInState(state, input));
|
||||
}
|
||||
|
||||
export function useClearReviewDraft(): ReviewDraftStoreActions["clearReview"] {
|
||||
return useReviewDraftStore((state) => state.clearReview);
|
||||
}
|
||||
@@ -313,7 +281,7 @@ export function getReviewDraftComments(key: string): ReviewDraftComment[] | unde
|
||||
}
|
||||
|
||||
export function resetReviewDraftStore(): void {
|
||||
useReviewDraftStore.setState({ drafts: {}, diffModeOverrides: {} });
|
||||
useReviewDraftStore.setState({ drafts: {} });
|
||||
}
|
||||
|
||||
export function useReviewDraftCommentsForAttachment(input: {
|
||||
@@ -331,18 +299,6 @@ export function useReviewCommentCount(key: string): number {
|
||||
return useReviewDraftStore((state) => state.drafts[key]?.length ?? 0);
|
||||
}
|
||||
|
||||
export function useResolvedDiffMode(input: {
|
||||
scopeKey: string;
|
||||
hasUncommittedChanges: boolean;
|
||||
}): ReviewDraftMode {
|
||||
return useReviewDraftStore((state) =>
|
||||
resolveDiffMode({
|
||||
override: state.diffModeOverrides[input.scopeKey],
|
||||
hasUncommittedChanges: input.hasUncommittedChanges,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function useReviewAttachmentSnapshot(input: {
|
||||
key: string;
|
||||
diffFiles: readonly ParsedDiffFile[];
|
||||
|
||||
@@ -168,7 +168,7 @@ function comment(overrides: Partial<ReviewDraftComment> = {}): ReviewDraftCommen
|
||||
|
||||
describe("useInlineReviewController", () => {
|
||||
beforeEach(() => {
|
||||
useReviewDraftStore.setState({ drafts: {}, diffModeOverrides: {} });
|
||||
useReviewDraftStore.setState({ drafts: {} });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -302,6 +302,7 @@ describe("ReplicaCache", () => {
|
||||
kind: "plugin",
|
||||
id: "reports/test-report/1",
|
||||
pluginId: "reports",
|
||||
pluginItemId: "test-report/1",
|
||||
itemKind: "test-report",
|
||||
version: 1,
|
||||
data: { passed: 4, failed: 0 },
|
||||
@@ -320,6 +321,38 @@ describe("ReplicaCache", () => {
|
||||
expect((await reader.readTimeline(SERVER_ID, "agent-1"))?.items).toEqual([pluginItem]);
|
||||
});
|
||||
|
||||
it("drops cached plugin timeline items without a plugin-local id", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const writer = createCache(storage);
|
||||
const pluginItem = {
|
||||
kind: "plugin",
|
||||
id: "reports/test-report/1",
|
||||
pluginId: "reports",
|
||||
pluginItemId: "test-report/1",
|
||||
itemKind: "test-report",
|
||||
version: 1,
|
||||
data: { passed: 4 },
|
||||
timestamp: new Date("2026-07-18T08:02:00.000Z"),
|
||||
} satisfies StreamItem;
|
||||
writer.commitTimeline(SERVER_ID, "agent-1", {
|
||||
agentId: "agent-1",
|
||||
items: [pluginItem],
|
||||
range: { epoch: "epoch-1", startSeq: 12, endSeq: 12 },
|
||||
hasOlder: true,
|
||||
});
|
||||
await writer.flush();
|
||||
const row = [...storage.rows.values()].find((candidate) => candidate.kind === "timeline");
|
||||
if (!row) throw new Error("timeline row was not written");
|
||||
const payload = JSON.parse(row.payload) as { items: Array<Record<string, unknown>> };
|
||||
delete payload.items[0]?.pluginItemId;
|
||||
storage.rows.set(`${row.serverId}:${row.kind}:${row.id}`, {
|
||||
...row,
|
||||
payload: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
expect(await createCache(storage).readTimeline(SERVER_ID, "agent-1")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reads one requested agent and the focused timeline without scanning directory rows", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const writer = createCache(storage);
|
||||
|
||||
@@ -125,6 +125,7 @@ const StoredTimelineItemSchema = z.discriminatedUnion("kind", [
|
||||
...TimelineItemBaseShape,
|
||||
kind: z.literal("plugin"),
|
||||
pluginId: z.string(),
|
||||
pluginItemId: z.string(),
|
||||
itemKind: z.string(),
|
||||
version: z.number().int().positive(),
|
||||
data: PluginTimelineDataSchema,
|
||||
@@ -449,6 +450,7 @@ function serializeTimelineItem(item: StreamItem): StoredTimelineItem | null {
|
||||
...base,
|
||||
kind: item.kind,
|
||||
pluginId: item.pluginId,
|
||||
pluginItemId: item.pluginItemId,
|
||||
itemKind: item.itemKind,
|
||||
version: item.version,
|
||||
data: item.data,
|
||||
@@ -457,6 +459,26 @@ function serializeTimelineItem(item: StreamItem): StoredTimelineItem | null {
|
||||
}
|
||||
|
||||
function deserializeTimelineItem(item: StoredTimelineItem): StreamItem {
|
||||
if (item.kind === "plugin") {
|
||||
return {
|
||||
id: item.id,
|
||||
...(item.timelineCursor ? { timelineCursor: item.timelineCursor } : {}),
|
||||
...(item.turnId ? { turnId: item.turnId } : {}),
|
||||
timestamp: new Date(item.timestamp),
|
||||
kind: item.kind,
|
||||
pluginId: item.pluginId,
|
||||
pluginItemId: item.pluginItemId,
|
||||
itemKind: item.itemKind,
|
||||
version: item.version,
|
||||
data: item.data,
|
||||
};
|
||||
}
|
||||
return deserializeBuiltinTimelineItem(item);
|
||||
}
|
||||
|
||||
function deserializeBuiltinTimelineItem(
|
||||
item: Exclude<StoredTimelineItem, { kind: "plugin" }>,
|
||||
): StreamItem {
|
||||
const base = {
|
||||
id: item.id,
|
||||
...(item.timelineCursor ? { timelineCursor: item.timelineCursor } : {}),
|
||||
@@ -528,15 +550,6 @@ function deserializeTimelineItem(item: StoredTimelineItem): StreamItem {
|
||||
},
|
||||
};
|
||||
}
|
||||
case "plugin":
|
||||
return {
|
||||
...base,
|
||||
kind: item.kind,
|
||||
pluginId: item.pluginId,
|
||||
itemKind: item.itemKind,
|
||||
version: item.version,
|
||||
data: item.data,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ import { isNative } from "@/constants/platform";
|
||||
import type { PluginThemeOption } from "@/plugins/themes";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { AppearancePreview } from "./appearance-preview";
|
||||
import { SidebarNavSection } from "./sidebar-nav-section";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Theme-reactive leaf icons (withUnistyles + uniProps color mapping — no
|
||||
@@ -709,6 +710,7 @@ export function AppearanceSection() {
|
||||
) : null}
|
||||
</View>
|
||||
</SettingsSection>
|
||||
<SidebarNavSection />
|
||||
<SettingsSection title={t("settings.appearance.fonts.title")}>
|
||||
<View style={settingsStyles.card}>
|
||||
{showInterfaceFontFamilyRow ? (
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { useCallback, useMemo, type ReactElement } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
CalendarClock,
|
||||
History,
|
||||
Plus,
|
||||
Search,
|
||||
type LucideIcon,
|
||||
} from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import { resolvePluginIcon } from "@/plugins/icons";
|
||||
import { SettingsSection } from "@/screens/settings/settings-section";
|
||||
import {
|
||||
builtinSidebarNavLabelKey,
|
||||
builtinSidebarNavShortcutAction,
|
||||
type BuiltinSidebarNavId,
|
||||
type SidebarNavItem,
|
||||
} from "@/sidebar-nav/model";
|
||||
import { useSidebarNavItems } from "@/sidebar-nav/use-sidebar-nav-items";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
|
||||
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
const ThemedArrowUp = withUnistyles(ArrowUp);
|
||||
const ThemedArrowDown = withUnistyles(ArrowDown);
|
||||
|
||||
const moveUpIcon = <ThemedArrowUp size={ICON_SIZE.sm} uniProps={mutedColorMapping} />;
|
||||
const moveDownIcon = <ThemedArrowDown size={ICON_SIZE.sm} uniProps={mutedColorMapping} />;
|
||||
|
||||
const BUILTIN_ICONS: Record<BuiltinSidebarNavId, LucideIcon> = {
|
||||
"new-workspace": Plus,
|
||||
history: History,
|
||||
search: Search,
|
||||
schedules: CalendarClock,
|
||||
};
|
||||
|
||||
function NavIcon({ Icon, color = "" }: { Icon: LucideIcon; color?: string }) {
|
||||
return <Icon size={ICON_SIZE.md} color={color} />;
|
||||
}
|
||||
|
||||
const ThemedNavIcon = withUnistyles(NavIcon);
|
||||
|
||||
function navItemIcon(item: SidebarNavItem): LucideIcon {
|
||||
return item.kind === "builtin" ? BUILTIN_ICONS[item.id] : resolvePluginIcon(item.group.icon);
|
||||
}
|
||||
|
||||
function navItemLabel(t: TFunction, item: SidebarNavItem): string {
|
||||
return item.kind === "builtin" ? t(builtinSidebarNavLabelKey(item.id)) : item.group.title;
|
||||
}
|
||||
|
||||
/** Own component so the row can stay hook-free about which items have a shortcut. */
|
||||
function NavItemShortcut({ item }: { item: SidebarNavItem }): ReactElement | null {
|
||||
const chord = useShortcutKeys(
|
||||
item.kind === "builtin" ? builtinSidebarNavShortcutAction(item.id) : null,
|
||||
);
|
||||
return chord ? <Shortcut chord={chord} /> : null;
|
||||
}
|
||||
|
||||
interface SidebarNavRowProps {
|
||||
item: SidebarNavItem;
|
||||
isFirst: boolean;
|
||||
isLast: boolean;
|
||||
onMove: (key: string, direction: "up" | "down") => void;
|
||||
onSetVisible: (key: string, visible: boolean) => void;
|
||||
}
|
||||
|
||||
function SidebarNavRow({
|
||||
item,
|
||||
isFirst,
|
||||
isLast,
|
||||
onMove,
|
||||
onSetVisible,
|
||||
}: SidebarNavRowProps): ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const label = navItemLabel(t, item);
|
||||
|
||||
const handleMoveUp = useCallback(() => onMove(item.key, "up"), [item.key, onMove]);
|
||||
const handleMoveDown = useCallback(() => onMove(item.key, "down"), [item.key, onMove]);
|
||||
const handleVisibleChange = useCallback(
|
||||
(visible: boolean) => onSetVisible(item.key, visible),
|
||||
[item.key, onSetVisible],
|
||||
);
|
||||
|
||||
const rowStyle = useMemo(
|
||||
() => [settingsStyles.row, isFirst ? null : settingsStyles.rowBorder, styles.row],
|
||||
[isFirst],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={rowStyle} testID={`sidebar-nav-item-${item.key}`}>
|
||||
<View style={styles.rowMain}>
|
||||
<ThemedNavIcon Icon={navItemIcon(item)} uniProps={mutedColorMapping} />
|
||||
<Text style={settingsStyles.rowTitle} numberOfLines={1}>
|
||||
{label}
|
||||
</Text>
|
||||
<NavItemShortcut item={item} />
|
||||
</View>
|
||||
<View style={styles.rowActions}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
leftIcon={moveUpIcon}
|
||||
onPress={handleMoveUp}
|
||||
disabled={isFirst}
|
||||
accessibilityLabel={t("settings.appearance.sidebar.moveUp")}
|
||||
testID={`sidebar-nav-move-up-${item.key}`}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
leftIcon={moveDownIcon}
|
||||
onPress={handleMoveDown}
|
||||
disabled={isLast}
|
||||
accessibilityLabel={t("settings.appearance.sidebar.moveDown")}
|
||||
testID={`sidebar-nav-move-down-${item.key}`}
|
||||
/>
|
||||
<Switch
|
||||
value={item.visible}
|
||||
onValueChange={handleVisibleChange}
|
||||
accessibilityLabel={label}
|
||||
testID={`sidebar-nav-toggle-${item.key}`}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function SidebarNavSection(): ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { items, setVisible, move } = useSidebarNavItems();
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t("settings.appearance.sidebar.title")}
|
||||
info={t("settings.appearance.sidebar.description")}
|
||||
testID="sidebar-nav-section"
|
||||
>
|
||||
<View style={settingsStyles.card}>
|
||||
{items.map((item, index) => (
|
||||
<SidebarNavRow
|
||||
key={item.key}
|
||||
item={item}
|
||||
isFirst={index === 0}
|
||||
isLast={index === items.length - 1}
|
||||
onMove={move}
|
||||
onSetVisible={setVisible}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
row: {
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
rowMain: {
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
rowActions: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
}));
|
||||
@@ -110,10 +110,10 @@ export function MetadataGenerationPage({ serverId }: { serverId: string }) {
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t("settings.metadataGeneration.title")}
|
||||
info={t("settings.metadataGeneration.description")}
|
||||
trailing={docsLink}
|
||||
testID="metadata-generation-settings"
|
||||
>
|
||||
<Text style={styles.description}>{t("settings.metadataGeneration.description")}</Text>
|
||||
<View style={settingsStyles.card}>
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
@@ -164,12 +164,6 @@ export function MetadataGenerationPage({ serverId }: { serverId: string }) {
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
description: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: theme.fontSize.sm * 1.45,
|
||||
marginHorizontal: theme.spacing[1],
|
||||
},
|
||||
loading: {
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
|
||||
@@ -166,6 +166,10 @@ vi.mock("@/components/ui/loading-spinner", () => ({
|
||||
LoadingSpinner: () => React.createElement("span", { "data-testid": "loading-spinner" }),
|
||||
}));
|
||||
|
||||
vi.mock("@/screens/settings/settings-info-tip", () => ({
|
||||
SettingsInfoTip: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/dropdown-menu", () => ({
|
||||
DropdownMenu: ({ children }: { children?: React.ReactNode }) =>
|
||||
React.createElement("div", null, children),
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import { Pressable, Text, View, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Info } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Text, View, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { SettingsInfoTip } from "@/screens/settings/settings-info-tip";
|
||||
|
||||
interface SettingsGroupProps {
|
||||
title: string;
|
||||
@@ -28,8 +26,6 @@ export function SettingsGroup({
|
||||
style,
|
||||
children,
|
||||
}: SettingsGroupProps) {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
const groupStyle = useMemo(() => [styles.group, style], [style]);
|
||||
return (
|
||||
<View style={groupStyle} testID={testID}>
|
||||
@@ -37,22 +33,11 @@ export function SettingsGroup({
|
||||
<View style={styles.titleRow}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
{info ? (
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile>
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("settings.groupInfo", { title })}
|
||||
testID={testID ? `${testID}-info` : undefined}
|
||||
hitSlop={8}
|
||||
style={styles.infoButton}
|
||||
>
|
||||
<Info size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="start" offset={8}>
|
||||
<Text style={styles.tooltipText}>{info}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<SettingsInfoTip
|
||||
title={title}
|
||||
info={info}
|
||||
testID={testID ? `${testID}-info` : undefined}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
{trailing}
|
||||
@@ -83,14 +68,4 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
infoButton: {
|
||||
padding: theme.spacing[1],
|
||||
marginLeft: -theme.spacing[1],
|
||||
},
|
||||
tooltipText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
maxWidth: 280,
|
||||
lineHeight: theme.fontSize.base * 1.4,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Pressable, Text } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Info } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
|
||||
const ThemedInfo = withUnistyles(Info);
|
||||
|
||||
const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
interface SettingsInfoTipProps {
|
||||
/** Header the tip belongs to; used for the accessibility label. */
|
||||
title: string;
|
||||
info: ReactNode;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one way a settings group or section explains itself: an info icon on the
|
||||
* header that opens a tooltip. Never a paragraph under the header — see
|
||||
* docs/design.md §7.
|
||||
*/
|
||||
export function SettingsInfoTip({ title, info, testID }: SettingsInfoTipProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile>
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("settings.groupInfo", { title })}
|
||||
testID={testID}
|
||||
hitSlop={8}
|
||||
style={styles.button}
|
||||
>
|
||||
<ThemedInfo size={ICON_SIZE.sm} uniProps={mutedIconMapping} />
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="start" offset={8}>
|
||||
<Text style={styles.tooltipText}>{info}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
button: {
|
||||
padding: theme.spacing[1],
|
||||
marginLeft: -theme.spacing[1],
|
||||
},
|
||||
tooltipText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
maxWidth: 280,
|
||||
lineHeight: theme.fontSize.base * 1.4,
|
||||
},
|
||||
}));
|
||||
@@ -1,10 +1,16 @@
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import { Text, View, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { SettingsInfoTip } from "@/screens/settings/settings-info-tip";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
|
||||
interface SettingsSectionProps {
|
||||
title: string;
|
||||
/**
|
||||
* What this section is for. Renders as an info tooltip on the header; a
|
||||
* paragraph between the header and the card is wrong (docs/design.md §7).
|
||||
*/
|
||||
info?: ReactNode;
|
||||
trailing?: ReactNode;
|
||||
testID?: string;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
@@ -23,6 +29,7 @@ interface SettingsSectionProps {
|
||||
*/
|
||||
export function SettingsSection({
|
||||
title,
|
||||
info,
|
||||
trailing,
|
||||
testID,
|
||||
style,
|
||||
@@ -36,7 +43,16 @@ export function SettingsSection({
|
||||
return (
|
||||
<View style={sectionStyle} testID={testID}>
|
||||
<View style={styles.header}>
|
||||
<Text style={settingsStyles.sectionHeaderTitle}>{title}</Text>
|
||||
<View style={styles.titleRow}>
|
||||
<Text style={settingsStyles.sectionHeaderTitle}>{title}</Text>
|
||||
{info ? (
|
||||
<SettingsInfoTip
|
||||
title={title}
|
||||
info={info}
|
||||
testID={testID ? `${testID}-info` : undefined}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
{trailing}
|
||||
</View>
|
||||
<View style={styles.content}>{children}</View>
|
||||
@@ -53,6 +69,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
marginBottom: theme.spacing[3],
|
||||
marginLeft: theme.spacing[1],
|
||||
},
|
||||
titleRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
content: {
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
|
||||
@@ -68,6 +68,7 @@ import type { Theme } from "@/styles/theme";
|
||||
import { RenderProfile } from "@/utils/render-profiler";
|
||||
import { TrailingActionScrim } from "@/components/ui/trailing-action-scrim";
|
||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||
import { useCompactTimeAgo } from "@/hooks/use-compact-time-ago";
|
||||
import { buildWorkspaceKeyboardHandlerId } from "@/keyboard/handler-id";
|
||||
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import { WorkspaceNewTabMenuContent } from "@/screens/workspace/workspace-new-tab-menu";
|
||||
@@ -81,6 +82,7 @@ import {
|
||||
HorizontalScrollBoundaryShades,
|
||||
useHorizontalScrollBoundary,
|
||||
} from "@/components/ui/horizontal-scroll-boundary";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
|
||||
const DROPDOWN_WIDTH = 220;
|
||||
const DEFAULT_INLINE_ADD_BUTTON_RESERVED_WIDTH = 36;
|
||||
@@ -108,6 +110,7 @@ const TAB_MIN_WIDTH = 96;
|
||||
const TAB_MAX_WIDTH = 160;
|
||||
const TAB_CLOSE_BUTTON_RESERVED_WIDTH = 0;
|
||||
const TAB_LABEL_LAYOUT_ALLOWANCE = 4;
|
||||
const AGENT_TOOLTIP_TITLE_MAX_LENGTH = 80;
|
||||
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
const ThemedX = withUnistyles(X);
|
||||
@@ -136,6 +139,56 @@ function updateMeasuredWidth(
|
||||
setWidth((current) => retainWorkspaceTabMeasuredWidth(current, nextWidth));
|
||||
}
|
||||
|
||||
function normalizeAgentTooltipTitle(title: string): string {
|
||||
return title.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function formatAgentTooltipTitle(singleLineTitle: string): string {
|
||||
if (singleLineTitle.length <= AGENT_TOOLTIP_TITLE_MAX_LENGTH) return singleLineTitle;
|
||||
return `${singleLineTitle.slice(0, AGENT_TOOLTIP_TITLE_MAX_LENGTH - 1).trimEnd()}…`;
|
||||
}
|
||||
|
||||
function formatAgentTooltipActivity(compactActivity: string): string {
|
||||
if (compactActivity === "now") return "just now";
|
||||
if (/^\d/.test(compactActivity)) return `${compactActivity} ago`;
|
||||
return compactActivity;
|
||||
}
|
||||
|
||||
function AgentTabTooltipBody({
|
||||
serverId,
|
||||
agentId,
|
||||
title,
|
||||
}: {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
title: string;
|
||||
}) {
|
||||
const lastActivityAt = useSessionStore((state) => {
|
||||
const session = state.sessions[serverId];
|
||||
const agent = session?.agents.get(agentId) ?? session?.agentDetails.get(agentId) ?? null;
|
||||
return state.agentLastActivity.get(agentId) ?? agent?.lastActivityAt ?? null;
|
||||
});
|
||||
const compactActivity = useCompactTimeAgo(lastActivityAt);
|
||||
const activity = formatAgentTooltipActivity(compactActivity);
|
||||
|
||||
return (
|
||||
<View style={styles.tooltipAgentContent}>
|
||||
<Text style={styles.agentTooltipTitle} numberOfLines={1} ellipsizeMode="tail">
|
||||
{title}
|
||||
</Text>
|
||||
<View style={styles.tooltipAgentMetadata}>
|
||||
<Text style={styles.tooltipAgentId}>{agentId.slice(0, 7)}</Text>
|
||||
{activity ? (
|
||||
<>
|
||||
<Text style={styles.tooltipAgentSeparator}>·</Text>
|
||||
<Text style={styles.tooltipAgentActivity}>{activity}</Text>
|
||||
</>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function TabLabelMeasurement({
|
||||
tabKey,
|
||||
label,
|
||||
@@ -648,6 +701,7 @@ function TabHandleContent({
|
||||
}
|
||||
|
||||
function TabChip({
|
||||
serverId,
|
||||
tab,
|
||||
isActive,
|
||||
isDragging,
|
||||
@@ -659,12 +713,14 @@ function TabChip({
|
||||
isClosingTab,
|
||||
presentation,
|
||||
tooltipLabel,
|
||||
accessibilityLabel,
|
||||
resolvedTab,
|
||||
setHoveredCloseTabKey,
|
||||
onNavigateTab,
|
||||
onCloseTab,
|
||||
dragHandleProps,
|
||||
}: {
|
||||
serverId: string;
|
||||
tab: WorkspaceTabDescriptor;
|
||||
isActive: boolean;
|
||||
isDragging: boolean;
|
||||
@@ -676,6 +732,7 @@ function TabChip({
|
||||
isClosingTab: boolean;
|
||||
presentation: WorkspaceTabPresentation;
|
||||
tooltipLabel: string;
|
||||
accessibilityLabel: string;
|
||||
resolvedTab: WorkspaceDesktopTabActions;
|
||||
setHoveredCloseTabKey: Dispatch<SetStateAction<string | null>>;
|
||||
onNavigateTab: (tabId: string) => void;
|
||||
@@ -786,7 +843,7 @@ function TabChip({
|
||||
onPressIn={handleNavigateTab}
|
||||
onPress={handleNavigateTab}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={tooltipLabel}
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
accessibilityState={tabAccessibilityState}
|
||||
aria-selected={isActive}
|
||||
>
|
||||
@@ -809,10 +866,11 @@ function TabChip({
|
||||
testID={`workspace-tab-tooltip-${testIdentity}`}
|
||||
>
|
||||
{tab.target.kind === "agent" ? (
|
||||
<View style={styles.tooltipAgentRow}>
|
||||
<Text style={styles.newTabTooltipText}>{tooltipLabel}</Text>
|
||||
<Text style={styles.tooltipAgentId}>{tab.target.agentId.slice(0, 7)}</Text>
|
||||
</View>
|
||||
<AgentTabTooltipBody
|
||||
serverId={serverId}
|
||||
agentId={tab.target.agentId}
|
||||
title={tooltipLabel}
|
||||
/>
|
||||
) : (
|
||||
<Text style={styles.newTabTooltipText}>{tooltipLabel}</Text>
|
||||
)}
|
||||
@@ -1191,6 +1249,7 @@ function ResolvedWorkspaceDesktopTabsRow({
|
||||
return (
|
||||
<ResolvedDesktopTabChip
|
||||
key={`${item.tab.key}:${item.tab.kind}`}
|
||||
serverId={normalizedServerId}
|
||||
item={item}
|
||||
isFocused={isFocused}
|
||||
isDragging={isActive}
|
||||
@@ -1223,6 +1282,7 @@ function ResolvedWorkspaceDesktopTabsRow({
|
||||
isFocused,
|
||||
layout.closeButtonPolicy,
|
||||
layout.items,
|
||||
normalizedServerId,
|
||||
onCloseOtherTabs,
|
||||
onCloseTab,
|
||||
onCloseTabsToLeft,
|
||||
@@ -1336,6 +1396,7 @@ function ResolvedWorkspaceDesktopTabsRow({
|
||||
return <RenderProfile id="WorkspaceDesktopTabsRow">{row}</RenderProfile>;
|
||||
}
|
||||
function ResolvedDesktopTabChip({
|
||||
serverId,
|
||||
item,
|
||||
isFocused,
|
||||
isDragging,
|
||||
@@ -1361,6 +1422,7 @@ function ResolvedDesktopTabChip({
|
||||
showDropIndicatorBefore,
|
||||
showDropIndicatorAfter,
|
||||
}: {
|
||||
serverId: string;
|
||||
item: ResolvedWorkspaceDesktopTabRowItem;
|
||||
isFocused: boolean;
|
||||
isDragging: boolean;
|
||||
@@ -1424,10 +1486,18 @@ function ResolvedDesktopTabChip({
|
||||
],
|
||||
);
|
||||
|
||||
const tooltipLabel =
|
||||
const rawTooltipLabel =
|
||||
presentation.titleState === "loading"
|
||||
? t("workspace.tabs.loadingAgentTitle")
|
||||
: presentation.tooltip;
|
||||
const accessibilityLabel =
|
||||
item.tab.target.kind === "agent"
|
||||
? normalizeAgentTooltipTitle(rawTooltipLabel)
|
||||
: rawTooltipLabel;
|
||||
const tooltipLabel =
|
||||
item.tab.target.kind === "agent"
|
||||
? formatAgentTooltipTitle(accessibilityLabel)
|
||||
: rawTooltipLabel;
|
||||
|
||||
return (
|
||||
<View style={styles.tabSlot}>
|
||||
@@ -1435,6 +1505,7 @@ function ResolvedDesktopTabChip({
|
||||
<View style={[styles.tabDropIndicator, styles.tabDropIndicatorBefore]} />
|
||||
) : null}
|
||||
<TabChip
|
||||
serverId={serverId}
|
||||
tab={item.tab}
|
||||
isActive={item.isActive}
|
||||
isDragging={isDragging}
|
||||
@@ -1446,6 +1517,7 @@ function ResolvedDesktopTabChip({
|
||||
isClosingTab={item.isClosingTab}
|
||||
presentation={presentation}
|
||||
tooltipLabel={tooltipLabel}
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
resolvedTab={resolvedTab}
|
||||
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
||||
onNavigateTab={onNavigateTab}
|
||||
@@ -1641,15 +1713,31 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
tooltipAgentRow: {
|
||||
tooltipAgentContent: {
|
||||
gap: theme.spacing[0.5],
|
||||
maxWidth: 420,
|
||||
},
|
||||
agentTooltipTitle: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
tooltipAgentMetadata: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
tooltipAgentId: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
tooltipAgentSeparator: {
|
||||
color: theme.colors.foregroundExtraMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
tooltipAgentActivity: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
menuItemHint: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { PluginSidebarGroup } from "@/plugins/sidebar-groups";
|
||||
import {
|
||||
builtinSidebarNavShortcutAction,
|
||||
moveSidebarNavItem,
|
||||
pluginSidebarNavKey,
|
||||
resolveSidebarNavItems,
|
||||
setSidebarNavItemVisible,
|
||||
type SidebarNavItem,
|
||||
type SidebarNavPreference,
|
||||
} from "./model";
|
||||
|
||||
function group(pluginId: string, contributionId: string): PluginSidebarGroup {
|
||||
return {
|
||||
key: `${pluginId}/sidebar/${contributionId}`,
|
||||
pluginId,
|
||||
contributionId,
|
||||
title: contributionId,
|
||||
icon: "puzzle",
|
||||
targets: [],
|
||||
};
|
||||
}
|
||||
|
||||
const kanban = group("kanban", "board");
|
||||
const notes = group("notes", "inbox");
|
||||
const kanbanKey = pluginSidebarNavKey(kanban);
|
||||
const notesKey = pluginSidebarNavKey(notes);
|
||||
|
||||
function summarize(items: readonly SidebarNavItem[]): SidebarNavPreference[] {
|
||||
return items.map(({ key, visible }) => ({ key, visible }));
|
||||
}
|
||||
|
||||
describe("resolveSidebarNavItems", () => {
|
||||
it("yields builtins then plugins, all visible, when nothing is stored", () => {
|
||||
const items = resolveSidebarNavItems({ pluginGroups: [kanban, notes], preferences: [] });
|
||||
|
||||
expect(summarize(items)).toEqual([
|
||||
{ key: "new-workspace", visible: true },
|
||||
{ key: "history", visible: true },
|
||||
{ key: "search", visible: true },
|
||||
{ key: "schedules", visible: true },
|
||||
{ key: kanbanKey, visible: true },
|
||||
{ key: notesKey, visible: true },
|
||||
]);
|
||||
expect(items[4]).toEqual({ kind: "plugin", key: kanbanKey, group: kanban, visible: true });
|
||||
expect(items[0]).toEqual({
|
||||
kind: "builtin",
|
||||
key: "new-workspace",
|
||||
id: "new-workspace",
|
||||
visible: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the stored order and appends newly available items as visible", () => {
|
||||
const items = resolveSidebarNavItems({
|
||||
pluginGroups: [notes, kanban],
|
||||
preferences: [
|
||||
{ key: kanbanKey, visible: false },
|
||||
{ key: "schedules", visible: true },
|
||||
{ key: "new-workspace", visible: false },
|
||||
],
|
||||
});
|
||||
|
||||
expect(summarize(items)).toEqual([
|
||||
{ key: kanbanKey, visible: false },
|
||||
{ key: "schedules", visible: true },
|
||||
{ key: "new-workspace", visible: false },
|
||||
{ key: "history", visible: true },
|
||||
{ key: "search", visible: true },
|
||||
{ key: notesKey, visible: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips keys that are unknown or not currently available", () => {
|
||||
const items = resolveSidebarNavItems({
|
||||
pluginGroups: [],
|
||||
preferences: [
|
||||
{ key: notesKey, visible: false },
|
||||
{ key: "bogus", visible: true },
|
||||
{ key: "history", visible: true },
|
||||
],
|
||||
});
|
||||
|
||||
expect(items.map((item) => item.key)).toEqual([
|
||||
"history",
|
||||
"new-workspace",
|
||||
"search",
|
||||
"schedules",
|
||||
]);
|
||||
});
|
||||
|
||||
it("lets the first of duplicate keys win", () => {
|
||||
const items = resolveSidebarNavItems({
|
||||
pluginGroups: [],
|
||||
preferences: [
|
||||
{ key: "history", visible: false },
|
||||
{ key: "history", visible: true },
|
||||
],
|
||||
});
|
||||
|
||||
expect(summarize(items)).toEqual([
|
||||
{ key: "history", visible: false },
|
||||
{ key: "new-workspace", visible: true },
|
||||
{ key: "search", visible: true },
|
||||
{ key: "schedules", visible: true },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setSidebarNavItemVisible", () => {
|
||||
it("toggles one item and writes the full resolved order", () => {
|
||||
const items = resolveSidebarNavItems({ pluginGroups: [kanban], preferences: [] });
|
||||
|
||||
const next = setSidebarNavItemVisible({ items, key: "search", visible: false, previous: [] });
|
||||
|
||||
expect(next).toEqual([
|
||||
{ key: "new-workspace", visible: true },
|
||||
{ key: "history", visible: true },
|
||||
{ key: "search", visible: false },
|
||||
{ key: "schedules", visible: true },
|
||||
{ key: kanbanKey, visible: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("carries preferences for unavailable plugins through an unrelated edit", () => {
|
||||
const previous: SidebarNavPreference[] = [
|
||||
{ key: notesKey, visible: false },
|
||||
{ key: "history", visible: true },
|
||||
];
|
||||
const items = resolveSidebarNavItems({ pluginGroups: [], preferences: previous });
|
||||
|
||||
const next = setSidebarNavItemVisible({ items, key: "history", visible: false, previous });
|
||||
|
||||
expect(next).toEqual([
|
||||
{ key: notesKey, visible: false },
|
||||
{ key: "history", visible: false },
|
||||
{ key: "new-workspace", visible: true },
|
||||
{ key: "search", visible: true },
|
||||
{ key: "schedules", visible: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps an unavailable plugin in its configured position", () => {
|
||||
const previous: SidebarNavPreference[] = [
|
||||
{ key: "new-workspace", visible: true },
|
||||
{ key: notesKey, visible: false },
|
||||
{ key: "history", visible: true },
|
||||
{ key: "search", visible: true },
|
||||
{ key: "schedules", visible: true },
|
||||
];
|
||||
const items = resolveSidebarNavItems({ pluginGroups: [], preferences: previous });
|
||||
|
||||
const next = setSidebarNavItemVisible({ items, key: "history", visible: false, previous });
|
||||
|
||||
expect(next).toEqual([
|
||||
{ key: "new-workspace", visible: true },
|
||||
{ key: notesKey, visible: false },
|
||||
{ key: "history", visible: false },
|
||||
{ key: "search", visible: true },
|
||||
{ key: "schedules", visible: true },
|
||||
]);
|
||||
expect(summarize(resolveSidebarNavItems({ pluginGroups: [notes], preferences: next }))).toEqual(
|
||||
next,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns the normalized list unchanged for an unknown key", () => {
|
||||
const items = resolveSidebarNavItems({ pluginGroups: [], preferences: [] });
|
||||
|
||||
const next = setSidebarNavItemVisible({ items, key: "bogus", visible: false, previous: [] });
|
||||
|
||||
expect(next).toEqual(summarize(items));
|
||||
});
|
||||
});
|
||||
|
||||
describe("moveSidebarNavItem", () => {
|
||||
const items = resolveSidebarNavItems({ pluginGroups: [kanban], preferences: [] });
|
||||
|
||||
it("moves an item up", () => {
|
||||
const next = moveSidebarNavItem({ items, key: "search", direction: "up", previous: [] });
|
||||
|
||||
expect(next.map((preference) => preference.key)).toEqual([
|
||||
"new-workspace",
|
||||
"search",
|
||||
"history",
|
||||
"schedules",
|
||||
kanbanKey,
|
||||
]);
|
||||
});
|
||||
|
||||
it("moves an item down", () => {
|
||||
const next = moveSidebarNavItem({ items, key: "schedules", direction: "down", previous: [] });
|
||||
|
||||
expect(next.map((preference) => preference.key)).toEqual([
|
||||
"new-workspace",
|
||||
"history",
|
||||
"search",
|
||||
kanbanKey,
|
||||
"schedules",
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves the order alone at the boundaries", () => {
|
||||
const first = moveSidebarNavItem({
|
||||
items,
|
||||
key: "new-workspace",
|
||||
direction: "up",
|
||||
previous: [],
|
||||
});
|
||||
const last = moveSidebarNavItem({ items, key: kanbanKey, direction: "down", previous: [] });
|
||||
|
||||
expect(first).toEqual(summarize(items));
|
||||
expect(last).toEqual(summarize(items));
|
||||
});
|
||||
|
||||
it("returns the normalized list unchanged for an unknown key", () => {
|
||||
const next = moveSidebarNavItem({ items, key: "bogus", direction: "down", previous: [] });
|
||||
|
||||
expect(next).toEqual(summarize(items));
|
||||
});
|
||||
|
||||
it("drops duplicate carried-over keys", () => {
|
||||
const previous: SidebarNavPreference[] = [
|
||||
{ key: notesKey, visible: false },
|
||||
{ key: notesKey, visible: true },
|
||||
];
|
||||
|
||||
const next = moveSidebarNavItem({ items, key: "history", direction: "up", previous });
|
||||
|
||||
expect(next.filter((preference) => preference.key === notesKey)).toEqual([
|
||||
{ key: notesKey, visible: false },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("builtinSidebarNavShortcutAction", () => {
|
||||
it("maps only the builtins that have a keyboard shortcut", () => {
|
||||
expect(builtinSidebarNavShortcutAction("new-workspace")).toBe("new-workspace");
|
||||
expect(builtinSidebarNavShortcutAction("search")).toBe("toggle-command-center");
|
||||
expect(builtinSidebarNavShortcutAction("history")).toBeNull();
|
||||
expect(builtinSidebarNavShortcutAction("schedules")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
import type { PluginSidebarGroup } from "@/plugins/sidebar-groups";
|
||||
|
||||
export const BUILTIN_SIDEBAR_NAV_IDS = ["new-workspace", "history", "search", "schedules"] as const;
|
||||
export type BuiltinSidebarNavId = (typeof BUILTIN_SIDEBAR_NAV_IDS)[number];
|
||||
|
||||
/** Persisted shape. Array order is the display order. */
|
||||
export interface SidebarNavPreference {
|
||||
key: string;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
export interface BuiltinSidebarNavItem {
|
||||
kind: "builtin";
|
||||
key: BuiltinSidebarNavId;
|
||||
id: BuiltinSidebarNavId;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
export interface PluginSidebarNavItem {
|
||||
kind: "plugin";
|
||||
key: string;
|
||||
group: PluginSidebarGroup;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
export type SidebarNavItem = BuiltinSidebarNavItem | PluginSidebarNavItem;
|
||||
|
||||
const BUILTIN_LABEL_KEYS: Record<BuiltinSidebarNavId, string> = {
|
||||
"new-workspace": "sidebar.actions.newWorkspace",
|
||||
history: "sidebar.sections.sessions",
|
||||
search: "sidebar.sections.search",
|
||||
schedules: "sidebar.sections.schedules",
|
||||
};
|
||||
|
||||
export function builtinSidebarNavLabelKey(id: BuiltinSidebarNavId): string {
|
||||
return BUILTIN_LABEL_KEYS[id];
|
||||
}
|
||||
|
||||
/**
|
||||
* Shortcut action ids (`resolveShortcutKeysForAction`) for the builtins that have one.
|
||||
* Both the sidebar row and the Appearance settings row read the badge from here so the
|
||||
* two never disagree about which shortcut belongs to which item.
|
||||
*/
|
||||
const BUILTIN_SHORTCUT_ACTIONS: Record<BuiltinSidebarNavId, string | null> = {
|
||||
"new-workspace": "new-workspace",
|
||||
history: null,
|
||||
search: "toggle-command-center",
|
||||
schedules: null,
|
||||
};
|
||||
|
||||
export function builtinSidebarNavShortcutAction(id: BuiltinSidebarNavId): string | null {
|
||||
return BUILTIN_SHORTCUT_ACTIONS[id];
|
||||
}
|
||||
|
||||
export function pluginSidebarNavKey(
|
||||
group: Pick<PluginSidebarGroup, "pluginId" | "contributionId">,
|
||||
): string {
|
||||
return `plugin:${group.pluginId}:${group.contributionId}`;
|
||||
}
|
||||
|
||||
function isBuiltinSidebarNavId(key: string): key is BuiltinSidebarNavId {
|
||||
return (BUILTIN_SIDEBAR_NAV_IDS as readonly string[]).includes(key);
|
||||
}
|
||||
|
||||
export function resolveSidebarNavItems(input: {
|
||||
pluginGroups: readonly PluginSidebarGroup[];
|
||||
preferences: readonly SidebarNavPreference[];
|
||||
}): SidebarNavItem[] {
|
||||
const groupsByKey = new Map(
|
||||
input.pluginGroups.map((group) => [pluginSidebarNavKey(group), group] as const),
|
||||
);
|
||||
const items: SidebarNavItem[] = [];
|
||||
const placed = new Set<string>();
|
||||
|
||||
for (const preference of input.preferences) {
|
||||
if (placed.has(preference.key)) continue;
|
||||
const group = groupsByKey.get(preference.key);
|
||||
if (group) {
|
||||
placed.add(preference.key);
|
||||
items.push({ kind: "plugin", key: preference.key, group, visible: preference.visible });
|
||||
} else if (isBuiltinSidebarNavId(preference.key)) {
|
||||
placed.add(preference.key);
|
||||
items.push({
|
||||
kind: "builtin",
|
||||
key: preference.key,
|
||||
id: preference.key,
|
||||
visible: preference.visible,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of BUILTIN_SIDEBAR_NAV_IDS) {
|
||||
if (placed.has(id)) continue;
|
||||
items.push({ kind: "builtin", key: id, id, visible: true });
|
||||
}
|
||||
for (const [key, group] of groupsByKey) {
|
||||
if (placed.has(key)) continue;
|
||||
items.push({ kind: "plugin", key, group, visible: true });
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolved items lead; entries for keys that are not currently available (a plugin that is
|
||||
* disconnected right now) follow so an unrelated edit does not erase them.
|
||||
*/
|
||||
function toPreferences(
|
||||
items: readonly SidebarNavItem[],
|
||||
previous: readonly SidebarNavPreference[],
|
||||
): SidebarNavPreference[] {
|
||||
const remaining = items.map(({ key, visible }) => ({ key, visible }));
|
||||
const availableKeys = new Set(remaining.map((preference) => preference.key));
|
||||
const preferences: SidebarNavPreference[] = [];
|
||||
const seenPrevious = new Set<string>();
|
||||
|
||||
for (const preference of previous) {
|
||||
if (seenPrevious.has(preference.key)) continue;
|
||||
seenPrevious.add(preference.key);
|
||||
|
||||
if (availableKeys.has(preference.key)) {
|
||||
const next = remaining.shift();
|
||||
if (next) preferences.push(next);
|
||||
continue;
|
||||
}
|
||||
|
||||
preferences.push({ key: preference.key, visible: preference.visible });
|
||||
}
|
||||
|
||||
preferences.push(...remaining);
|
||||
return preferences;
|
||||
}
|
||||
|
||||
export function setSidebarNavItemVisible(input: {
|
||||
items: readonly SidebarNavItem[];
|
||||
key: string;
|
||||
visible: boolean;
|
||||
previous: readonly SidebarNavPreference[];
|
||||
}): SidebarNavPreference[] {
|
||||
const items = input.items.map((item) =>
|
||||
item.key === input.key ? { ...item, visible: input.visible } : item,
|
||||
);
|
||||
return toPreferences(items, input.previous);
|
||||
}
|
||||
|
||||
export function moveSidebarNavItem(input: {
|
||||
items: readonly SidebarNavItem[];
|
||||
key: string;
|
||||
direction: "up" | "down";
|
||||
previous: readonly SidebarNavPreference[];
|
||||
}): SidebarNavPreference[] {
|
||||
const from = input.items.findIndex((item) => item.key === input.key);
|
||||
const to = input.direction === "up" ? from - 1 : from + 1;
|
||||
const canMove = from !== -1 && to >= 0 && to < input.items.length;
|
||||
if (!canMove) {
|
||||
return toPreferences(input.items, input.previous);
|
||||
}
|
||||
const items = [...input.items];
|
||||
const [moved] = items.splice(from, 1);
|
||||
items.splice(to, 0, moved);
|
||||
return toPreferences(items, input.previous);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import { useInstalledPlugins } from "@/plugins/registry";
|
||||
import { groupPluginSidebarContributions } from "@/plugins/sidebar-groups";
|
||||
import {
|
||||
moveSidebarNavItem,
|
||||
resolveSidebarNavItems,
|
||||
setSidebarNavItemVisible,
|
||||
type SidebarNavItem,
|
||||
} from "./model";
|
||||
|
||||
export interface UseSidebarNavItemsReturn {
|
||||
/** Every top-level item in display order, hidden ones included. */
|
||||
items: SidebarNavItem[];
|
||||
setVisible: (key: string, visible: boolean) => void;
|
||||
move: (key: string, direction: "up" | "down") => void;
|
||||
}
|
||||
|
||||
export function useSidebarNavItems(): UseSidebarNavItemsReturn {
|
||||
const plugins = useInstalledPlugins();
|
||||
const { settings, updateSettings } = useAppSettings();
|
||||
const preferences = settings.sidebarNavItems;
|
||||
const pluginGroups = useMemo(() => groupPluginSidebarContributions(plugins), [plugins]);
|
||||
|
||||
const items = useMemo(
|
||||
() =>
|
||||
resolveSidebarNavItems({
|
||||
pluginGroups,
|
||||
preferences,
|
||||
}),
|
||||
[pluginGroups, preferences],
|
||||
);
|
||||
|
||||
const setVisible = useCallback(
|
||||
(key: string, visible: boolean) => {
|
||||
void updateSettings((current) => {
|
||||
const previous = current.sidebarNavItems;
|
||||
const currentItems = resolveSidebarNavItems({ pluginGroups, preferences: previous });
|
||||
return {
|
||||
sidebarNavItems: setSidebarNavItemVisible({
|
||||
items: currentItems,
|
||||
key,
|
||||
visible,
|
||||
previous,
|
||||
}),
|
||||
};
|
||||
});
|
||||
},
|
||||
[pluginGroups, updateSettings],
|
||||
);
|
||||
|
||||
const move = useCallback(
|
||||
(key: string, direction: "up" | "down") => {
|
||||
void updateSettings((current) => {
|
||||
const previous = current.sidebarNavItems;
|
||||
const currentItems = resolveSidebarNavItems({ pluginGroups, preferences: previous });
|
||||
return {
|
||||
sidebarNavItems: moveSidebarNavItem({
|
||||
items: currentItems,
|
||||
key,
|
||||
direction,
|
||||
previous,
|
||||
}),
|
||||
};
|
||||
});
|
||||
},
|
||||
[pluginGroups, updateSettings],
|
||||
);
|
||||
|
||||
return { items, setVisible, move };
|
||||
}
|
||||
@@ -115,6 +115,64 @@ describe("viewed timeline persistence", () => {
|
||||
owner.dispose();
|
||||
});
|
||||
|
||||
it("reconciles an overlapping projected message against its cached cursor", async () => {
|
||||
useSessionStore.getState().initializeSession(SERVER_ID, null);
|
||||
const partial = "```mermaid\nflowchart LR\n Start --> Mid";
|
||||
const complete = `${partial}dle\n Middle --> Done\n\`\`\``;
|
||||
const owner = createOwner({
|
||||
readTimeline: async () => ({
|
||||
agentId: AGENT_ID,
|
||||
items: [item("cached", partial, 4)],
|
||||
range: { epoch: "epoch-1", startSeq: 1, endSeq: 4 },
|
||||
hasOlder: false,
|
||||
}),
|
||||
commitTimeline: () => undefined,
|
||||
});
|
||||
|
||||
owner.replaceVisibleAgentIds("test", [AGENT_ID]);
|
||||
await expect
|
||||
.poll(() =>
|
||||
selectAgentTimelineState(useSessionStore.getState().sessions[SERVER_ID], AGENT_ID),
|
||||
)
|
||||
.toMatchObject({ status: "painted" });
|
||||
|
||||
owner.applyTimelineResponse({
|
||||
requestId: "page-after-cache",
|
||||
agentId: AGENT_ID,
|
||||
agent: null,
|
||||
direction: "after",
|
||||
projection: "projected",
|
||||
reset: false,
|
||||
epoch: "epoch-1",
|
||||
window: { minSeq: 1, maxSeq: 5, nextSeq: 6 },
|
||||
startCursor: { epoch: "epoch-1", seq: 5 },
|
||||
endCursor: { epoch: "epoch-1", seq: 5 },
|
||||
entries: [
|
||||
{
|
||||
provider: "mock",
|
||||
item: { type: "assistant_message", text: complete },
|
||||
timestamp: "2026-08-26T10:00:00.000Z",
|
||||
seqStart: 2,
|
||||
seqEnd: 5,
|
||||
sourceSeqRanges: [{ startSeq: 2, endSeq: 5 }],
|
||||
collapsed: ["assistant_merge"],
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
hasNewer: false,
|
||||
hasOlder: false,
|
||||
staleCursor: false,
|
||||
gap: false,
|
||||
});
|
||||
|
||||
const session = useSessionStore.getState().sessions[SERVER_ID];
|
||||
expect([
|
||||
...(session?.agentStreamTail.get(AGENT_ID) ?? []),
|
||||
...(session?.agentStreamHead.get(AGENT_ID) ?? []),
|
||||
]).toMatchObject([{ kind: "assistant_message", text: complete }]);
|
||||
owner.dispose();
|
||||
});
|
||||
|
||||
it("does not let a late cache read overwrite newer network state", async () => {
|
||||
useSessionStore.getState().initializeSession(SERVER_ID, null);
|
||||
let release!: (value: CachedTimeline) => void;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages";
|
||||
import type { AgentTimelineItem, ToolCallDetail } from "@getpaseo/protocol/agent-types";
|
||||
import {
|
||||
createUserMessage,
|
||||
hydrateStreamState,
|
||||
@@ -22,17 +23,23 @@ import {
|
||||
// Test helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type TimelineResponseEntry = ProcessTimelineResponseInput["payload"]["entries"][number];
|
||||
|
||||
function makeTimelineEntry(
|
||||
seq: number,
|
||||
text: string,
|
||||
type: string = "assistant_message",
|
||||
type: "assistant_message" | "user_message" | "reasoning" = "assistant_message",
|
||||
seqEnd = seq,
|
||||
) {
|
||||
): TimelineResponseEntry {
|
||||
let item: AgentTimelineItem;
|
||||
if (type === "assistant_message") item = { type, text };
|
||||
else if (type === "user_message") item = { type, text };
|
||||
else item = { type, text };
|
||||
return {
|
||||
seqStart: seq,
|
||||
seqEnd,
|
||||
provider: "claude",
|
||||
item: { type, text },
|
||||
item,
|
||||
timestamp: new Date(1000 + seq).toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -41,8 +48,8 @@ function makeToolCallTimelineEntry(
|
||||
seq: number,
|
||||
callId: string,
|
||||
status: "running" | "completed",
|
||||
detail: Record<string, unknown>,
|
||||
) {
|
||||
detail: ToolCallDetail,
|
||||
): TimelineResponseEntry {
|
||||
return {
|
||||
seqStart: seq,
|
||||
seqEnd: seq,
|
||||
@@ -59,6 +66,23 @@ function makeToolCallTimelineEntry(
|
||||
};
|
||||
}
|
||||
|
||||
function makePluginTimelineEntry(seq: number, status: string): TimelineResponseEntry {
|
||||
return {
|
||||
seqStart: seq,
|
||||
seqEnd: seq,
|
||||
provider: "claude",
|
||||
item: {
|
||||
type: "plugin" as const,
|
||||
id: "review-1",
|
||||
pluginId: "review",
|
||||
kind: "review",
|
||||
version: 1,
|
||||
data: { status },
|
||||
},
|
||||
timestamp: new Date(1000 + seq).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function makeTimelineEvent(
|
||||
text: string,
|
||||
type: string = "assistant_message",
|
||||
@@ -3233,6 +3257,43 @@ describe("processTimelineResponse", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the newest plugin row across the older-page prepend boundary", () => {
|
||||
const currentTail = hydrateStreamState(
|
||||
[
|
||||
{
|
||||
event: {
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
item: makePluginTimelineEntry(3, "complete").item,
|
||||
},
|
||||
timestamp: new Date(3000),
|
||||
},
|
||||
],
|
||||
{ source: "canonical" },
|
||||
);
|
||||
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentTail,
|
||||
currentCursor: { epoch: "epoch-1", startSeq: 3, endSeq: 5 },
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
direction: "before",
|
||||
epoch: "epoch-1",
|
||||
startCursor: { seq: 1 },
|
||||
endCursor: { seq: 2 },
|
||||
entries: [makePluginTimelineEntry(1, "running")],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.tail).toHaveLength(1);
|
||||
expect(result.tail[0]).toMatchObject({
|
||||
kind: "plugin",
|
||||
id: "review/review-1",
|
||||
data: { status: "complete" },
|
||||
});
|
||||
});
|
||||
|
||||
it("removes a reconciled submitted prompt before coalescing a tool call at the pagination seam", () => {
|
||||
const clientMessageId = "client-boundary-prompt";
|
||||
const callId = "toolu_submitted_boundary";
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types";
|
||||
import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages";
|
||||
import { selectAgentTimelineState, useSessionStore } from "@/stores/session-store";
|
||||
import type { AssistantMessageItem, StreamItem, TodoEntry } from "@/types/stream";
|
||||
import type { TurnLivenessTransition } from "@/timeline/turn-liveness";
|
||||
import type { TimelineItemTransform } from "@/plugins/timeline";
|
||||
import {
|
||||
applyStreamEvent,
|
||||
flushHeadToTail,
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
mergeAgentToolCallItem,
|
||||
replaceWithCanonicalStream,
|
||||
reduceStreamUpdate,
|
||||
streamTimelineItemIdentity,
|
||||
upsertUserMessageAcrossStream,
|
||||
} from "@/types/stream";
|
||||
|
||||
@@ -43,9 +44,10 @@ export type TimelineReducerSideEffect =
|
||||
| { type: "catch_up"; cursor: { epoch: string; endSeq: number } }
|
||||
| { type: "flush_pending_updates" };
|
||||
|
||||
export type AgentStreamReducerSideEffect =
|
||||
| { type: "catch_up"; cursor: { epoch: string; endSeq: number } }
|
||||
| { type: "reproject" };
|
||||
export interface AgentStreamReducerSideEffect {
|
||||
type: "catch_up";
|
||||
cursor: { epoch: string; endSeq: number };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// processTimelineResponse
|
||||
@@ -129,7 +131,7 @@ interface TimelineResponseEntry {
|
||||
collapsed?: string[];
|
||||
provider: string;
|
||||
turnId?: string;
|
||||
item: Record<string, unknown>;
|
||||
item: AgentTimelineItem;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
@@ -156,7 +158,6 @@ export interface ProcessTimelineResponseInput {
|
||||
hasActiveInitDeferred: boolean;
|
||||
initRequestDirection: InitRequestDirection;
|
||||
sendingClientMessageIds: readonly string[];
|
||||
transformTimelineItem?: TimelineItemTransform;
|
||||
}
|
||||
|
||||
export interface ProcessTimelineResponseOutput {
|
||||
@@ -192,8 +193,9 @@ interface TimelinePathResult {
|
||||
}
|
||||
|
||||
function matchesProjectedRow(existing: StreamItem, incoming: StreamItem): boolean {
|
||||
if (isAgentToolCallItem(existing) && isAgentToolCallItem(incoming)) {
|
||||
return existing.payload.data.callId === incoming.payload.data.callId;
|
||||
const incomingIdentity = streamTimelineItemIdentity(incoming);
|
||||
if (incomingIdentity !== null) {
|
||||
return streamTimelineItemIdentity(existing) === incomingIdentity;
|
||||
}
|
||||
if (existing.kind === "assistant_message" && incoming.kind === "assistant_message") {
|
||||
return (
|
||||
@@ -391,7 +393,6 @@ function mergeTimelineWindow(args: {
|
||||
timestamp: Date;
|
||||
timelineCursor: { epoch: string; seq: number };
|
||||
}>;
|
||||
transformTimelineItem?: TimelineItemTransform;
|
||||
}): TimelinePathResult {
|
||||
const { timelineUnits, payload, currentTail, currentHead, currentCursor, toHydratedEvents } =
|
||||
args;
|
||||
@@ -433,7 +434,6 @@ function mergeTimelineWindow(args: {
|
||||
{
|
||||
source: "canonical",
|
||||
reservedItemIds,
|
||||
transformTimelineItem: args.transformTimelineItem,
|
||||
},
|
||||
);
|
||||
const reconciled = reconcilePromptWindowItems({
|
||||
@@ -512,7 +512,6 @@ function applyTimelineReplacePath(args: {
|
||||
toHydratedEvents: (
|
||||
units: TimelineUnit[],
|
||||
) => Array<{ event: AgentStreamEventPayload; timestamp: Date }>;
|
||||
transformTimelineItem?: TimelineItemTransform;
|
||||
}): TimelinePathResult {
|
||||
const {
|
||||
timelineUnits,
|
||||
@@ -526,7 +525,6 @@ function applyTimelineReplacePath(args: {
|
||||
} = args;
|
||||
const hydratedTail = hydrateStreamState(toHydratedEvents(timelineUnits), {
|
||||
source: "canonical",
|
||||
transformTimelineItem: args.transformTimelineItem,
|
||||
});
|
||||
const { tail, head, acknowledgedClientMessageIds } = replaceWithCanonicalStream({
|
||||
canonical: hydratedTail,
|
||||
@@ -697,19 +695,8 @@ function mergePrependedCanonicalTail(olderTail: StreamItem[], currentTail: Strea
|
||||
const olderLast = olderTail.at(-1);
|
||||
const currentFirst = currentTail[0];
|
||||
|
||||
if (
|
||||
olderLast &&
|
||||
currentFirst &&
|
||||
isAgentToolCallItem(olderLast) &&
|
||||
isAgentToolCallItem(currentFirst) &&
|
||||
olderLast.payload.data.callId === currentFirst.payload.data.callId
|
||||
) {
|
||||
return [
|
||||
...olderTail.slice(0, -1),
|
||||
mergeAgentToolCallItem(olderLast, currentFirst.payload.data, currentFirst.timestamp),
|
||||
...currentTail.slice(1),
|
||||
];
|
||||
}
|
||||
const identityMerge = mergeTimelineIdentityBoundary(olderTail, currentTail);
|
||||
if (identityMerge) return identityMerge;
|
||||
|
||||
if (olderLast?.kind !== "assistant_message" || currentFirst?.kind !== "assistant_message") {
|
||||
return [...olderTail, ...currentTail];
|
||||
@@ -726,6 +713,28 @@ function mergePrependedCanonicalTail(olderTail: StreamItem[], currentTail: Strea
|
||||
return [...olderTail.slice(0, -1), mergedAssistant, ...currentTail.slice(1)];
|
||||
}
|
||||
|
||||
function mergeTimelineIdentityBoundary(
|
||||
olderTail: StreamItem[],
|
||||
currentTail: StreamItem[],
|
||||
): StreamItem[] | null {
|
||||
const olderLast = olderTail.at(-1);
|
||||
const currentFirst = currentTail[0];
|
||||
if (!olderLast || !currentFirst) return null;
|
||||
const olderIdentity = streamTimelineItemIdentity(olderLast);
|
||||
if (olderIdentity === null || olderIdentity !== streamTimelineItemIdentity(currentFirst)) {
|
||||
return null;
|
||||
}
|
||||
if (olderLast.kind === "plugin" && currentFirst.kind === "plugin") {
|
||||
return [...olderTail.slice(0, -1), currentFirst, ...currentTail.slice(1)];
|
||||
}
|
||||
if (!isAgentToolCallItem(olderLast) || !isAgentToolCallItem(currentFirst)) return null;
|
||||
return [
|
||||
...olderTail.slice(0, -1),
|
||||
mergeAgentToolCallItem(olderLast, currentFirst.payload.data, currentFirst.timestamp),
|
||||
...currentTail.slice(1),
|
||||
];
|
||||
}
|
||||
|
||||
function mergeOlderTimelinePage(input: {
|
||||
page: StreamItem[];
|
||||
currentTail: StreamItem[];
|
||||
@@ -945,7 +954,6 @@ function applyCanonicalForwardUnit(params: {
|
||||
head: StreamItem[];
|
||||
unit: TimelineUnit;
|
||||
epoch: string;
|
||||
transformTimelineItem?: TimelineItemTransform;
|
||||
}): { tail: StreamItem[]; head: StreamItem[]; acknowledgedClientMessageIds: string[] } {
|
||||
const { event, timestamp, seqEnd } = params.unit;
|
||||
const timelineCursor = { epoch: params.epoch, seq: seqEnd };
|
||||
@@ -957,7 +965,6 @@ function applyCanonicalForwardUnit(params: {
|
||||
timestamp,
|
||||
source: "canonical",
|
||||
timelineCursor,
|
||||
transformTimelineItem: params.transformTimelineItem,
|
||||
});
|
||||
return {
|
||||
tail: applied.tail,
|
||||
@@ -970,7 +977,6 @@ function applyCanonicalForwardUnit(params: {
|
||||
tail: reduceStreamUpdate(params.tail, event, timestamp, {
|
||||
source: "canonical",
|
||||
timelineCursor,
|
||||
transformTimelineItem: params.transformTimelineItem,
|
||||
}),
|
||||
head: params.head,
|
||||
acknowledgedClientMessageIds: [],
|
||||
@@ -1001,7 +1007,6 @@ function applyCanonicalForwardUnit(params: {
|
||||
head: reduceStreamUpdate([], event, timestamp, {
|
||||
source: "canonical",
|
||||
timelineCursor,
|
||||
transformTimelineItem: params.transformTimelineItem,
|
||||
}),
|
||||
acknowledgedClientMessageIds: [],
|
||||
};
|
||||
@@ -1014,7 +1019,6 @@ function applyCanonicalForwardUnit(params: {
|
||||
timestamp,
|
||||
source: "canonical",
|
||||
timelineCursor,
|
||||
transformTimelineItem: params.transformTimelineItem,
|
||||
});
|
||||
return {
|
||||
tail: applied.tail,
|
||||
@@ -1029,7 +1033,6 @@ function applyAcceptedForwardTimelineUnits(params: {
|
||||
currentTail: StreamItem[];
|
||||
currentHead: StreamItem[];
|
||||
currentEndSeq: number | undefined;
|
||||
transformTimelineItem?: TimelineItemTransform;
|
||||
}): { tail: StreamItem[]; head: StreamItem[]; acknowledgedClientMessageIds: string[] } {
|
||||
const reconciled = reconcileOverlappingProjectedStreamItems({
|
||||
tail: params.currentTail,
|
||||
@@ -1048,7 +1051,6 @@ function applyAcceptedForwardTimelineUnits(params: {
|
||||
head,
|
||||
unit,
|
||||
epoch: params.epoch,
|
||||
transformTimelineItem: params.transformTimelineItem,
|
||||
});
|
||||
tail = applied.tail;
|
||||
head = applied.head;
|
||||
@@ -1134,7 +1136,6 @@ function applyAcceptedTimelinePage(input: {
|
||||
currentTail: StreamItem[];
|
||||
currentHead: StreamItem[];
|
||||
currentCursor: TimelineCursor | undefined;
|
||||
transformTimelineItem?: TimelineItemTransform;
|
||||
}): {
|
||||
tail: StreamItem[];
|
||||
head: StreamItem[];
|
||||
@@ -1151,7 +1152,6 @@ function applyAcceptedTimelinePage(input: {
|
||||
currentTail,
|
||||
currentHead,
|
||||
currentEndSeq: currentCursor?.endSeq,
|
||||
transformTimelineItem: input.transformTimelineItem,
|
||||
});
|
||||
}
|
||||
const olderTail = hydrateStreamState(
|
||||
@@ -1169,7 +1169,6 @@ function applyAcceptedTimelinePage(input: {
|
||||
: [item.id],
|
||||
),
|
||||
),
|
||||
transformTimelineItem: input.transformTimelineItem,
|
||||
},
|
||||
);
|
||||
return {
|
||||
@@ -1191,7 +1190,6 @@ function applyTimelineIncrementalPath(args: {
|
||||
currentTail: StreamItem[];
|
||||
currentHead: StreamItem[];
|
||||
currentCursor: TimelineCursor | undefined;
|
||||
transformTimelineItem?: TimelineItemTransform;
|
||||
}): TimelinePathResult {
|
||||
const { timelineUnits, payload, currentTail, currentHead, currentCursor } = args;
|
||||
let nextCursor: TimelineCursor | null | undefined = currentCursor;
|
||||
@@ -1229,7 +1227,6 @@ function applyTimelineIncrementalPath(args: {
|
||||
currentTail,
|
||||
currentHead,
|
||||
currentCursor,
|
||||
transformTimelineItem: args.transformTimelineItem,
|
||||
});
|
||||
|
||||
if (cursor && (!currentCursor || !timelineCursorEquals(currentCursor, cursor))) {
|
||||
@@ -1264,7 +1261,6 @@ export function processTimelineResponse(
|
||||
hasActiveInitDeferred,
|
||||
initRequestDirection,
|
||||
sendingClientMessageIds,
|
||||
transformTimelineItem,
|
||||
} = input;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
@@ -1365,7 +1361,6 @@ export function processTimelineResponse(
|
||||
currentHead,
|
||||
currentCursor,
|
||||
toHydratedEvents,
|
||||
transformTimelineItem,
|
||||
});
|
||||
} else if (replace || resumeTailPolicy.kind === "replace") {
|
||||
const isResumeReplacement = resumeTailPolicy.kind === "replace";
|
||||
@@ -1386,7 +1381,6 @@ export function processTimelineResponse(
|
||||
reset: payload.reset,
|
||||
}),
|
||||
toHydratedEvents,
|
||||
transformTimelineItem,
|
||||
});
|
||||
} else {
|
||||
const incrementalUnits =
|
||||
@@ -1399,7 +1393,6 @@ export function processTimelineResponse(
|
||||
currentTail,
|
||||
currentHead,
|
||||
currentCursor,
|
||||
transformTimelineItem,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1461,7 +1454,6 @@ export interface ProcessAgentStreamEventInput {
|
||||
currentCursor: TimelineCursor | undefined;
|
||||
hasAuthoritativeBaseline?: boolean;
|
||||
timestamp: Date;
|
||||
transformTimelineItem?: TimelineItemTransform;
|
||||
}
|
||||
|
||||
export interface ProcessAgentStreamEventOutput {
|
||||
@@ -1498,7 +1490,6 @@ export interface ProcessAgentStreamEventsInput {
|
||||
currentCursor: TimelineCursor | undefined;
|
||||
hasAuthoritativeBaseline?: boolean;
|
||||
isDetached?: boolean;
|
||||
transformTimelineItem?: TimelineItemTransform;
|
||||
}
|
||||
|
||||
export type AgentStreamReducerSnapshot = Omit<ProcessAgentStreamEventsInput, "events">;
|
||||
@@ -1608,7 +1599,6 @@ export function processAgentStreamEvent(
|
||||
currentCursor,
|
||||
timestamp,
|
||||
hasAuthoritativeBaseline = true,
|
||||
transformTimelineItem,
|
||||
} = input;
|
||||
|
||||
const sequencing = processTimelineSequencingGate({
|
||||
@@ -1622,11 +1612,6 @@ export function processAgentStreamEvent(
|
||||
event.type === "timeline" && seq !== undefined && epoch !== undefined
|
||||
? { epoch, seq }
|
||||
: undefined;
|
||||
const pluginProjection =
|
||||
sequencing.shouldApplyStreamEvent && event.type === "timeline"
|
||||
? transformTimelineItem?.(event.item)
|
||||
: undefined;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Apply stream event to tail/head
|
||||
// ------------------------------------------------------------------
|
||||
@@ -1688,10 +1673,7 @@ export function processAgentStreamEvent(
|
||||
...(sequencing.shouldApplyStreamEvent && event.type === "timeline" && event.item.type === "todo"
|
||||
? { taskSnapshot: event.item.items }
|
||||
: {}),
|
||||
sideEffects:
|
||||
pluginProjection === undefined
|
||||
? sequencing.sideEffects
|
||||
: [...sequencing.sideEffects, { type: "reproject" }],
|
||||
sideEffects: sequencing.sideEffects,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1730,7 +1712,6 @@ export function processAgentStreamEvents(
|
||||
currentCursor: cursor,
|
||||
hasAuthoritativeBaseline: input.hasAuthoritativeBaseline,
|
||||
timestamp: reducerEvent.timestamp,
|
||||
transformTimelineItem: input.transformTimelineItem,
|
||||
});
|
||||
|
||||
tail = result.tail;
|
||||
@@ -1760,9 +1741,7 @@ export function processAgentStreamEvents(
|
||||
cursorChanged,
|
||||
acknowledgedClientMessageIds: [...acknowledgedClientMessageIds],
|
||||
...(taskSnapshot !== undefined ? { taskSnapshot } : {}),
|
||||
sideEffects: sideEffects.some((effect) => effect.type === "reproject")
|
||||
? [...sideEffects.filter((effect) => effect.type !== "reproject"), { type: "reproject" }]
|
||||
: sideEffects,
|
||||
sideEffects,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1877,9 +1856,7 @@ export interface CreateSessionAgentStreamReducerQueueInput {
|
||||
state: (prev: Map<string, TimelineCursor>) => Map<string, TimelineCursor>,
|
||||
) => void;
|
||||
recoverTimelineGap: (agentId: string, cursor: { epoch: string; endSeq: number }) => void;
|
||||
reprojectTimeline: (agentId: string) => void;
|
||||
onCommitted?: (agentId: string) => void;
|
||||
transformTimelineItem?: TimelineItemTransform;
|
||||
}
|
||||
|
||||
interface ScheduledReducerFlush {
|
||||
@@ -1933,14 +1910,8 @@ function cancelAgentStreamReducerFlush(id: number) {
|
||||
export function createSessionAgentStreamReducerQueue(
|
||||
input: CreateSessionAgentStreamReducerQueueInput,
|
||||
): AgentStreamReducerQueue {
|
||||
const {
|
||||
serverId,
|
||||
setAgentStreamState,
|
||||
setAgentTimelineCursor,
|
||||
recoverTimelineGap,
|
||||
reprojectTimeline,
|
||||
onCommitted,
|
||||
} = input;
|
||||
const { serverId, setAgentStreamState, setAgentTimelineCursor, recoverTimelineGap, onCommitted } =
|
||||
input;
|
||||
|
||||
return createAgentStreamReducerQueue({
|
||||
getSnapshot: (agentId) => {
|
||||
@@ -1952,7 +1923,6 @@ export function createSessionAgentStreamReducerQueue(
|
||||
currentCursor: timeline.status === "synced" ? (timeline.range ?? undefined) : undefined,
|
||||
hasAuthoritativeBaseline: timeline.status === "synced",
|
||||
isDetached: timeline.status === "synced" && timeline.newer === "available",
|
||||
transformTimelineItem: input.transformTimelineItem,
|
||||
};
|
||||
},
|
||||
commit: (agentId, result, events) => {
|
||||
@@ -2006,8 +1976,6 @@ export function createSessionAgentStreamReducerQueue(
|
||||
for (const effect of sideEffects) {
|
||||
if (effect.type === "catch_up") {
|
||||
recoverTimelineGap(agentId, effect.cursor);
|
||||
} else if (effect.type === "reproject") {
|
||||
reprojectTimeline(agentId);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { expect, test, vi } from "vitest";
|
||||
import type { ProjectedTimelineForwardFetchPlan } from "./timeline-sync-plan";
|
||||
import { createViewedTimelineSync } from "./viewed-timeline-sync";
|
||||
import {
|
||||
consumeForcedTimelineTailReplacement,
|
||||
createViewedTimelineSync,
|
||||
type TimelineResponsePayload,
|
||||
} from "./viewed-timeline-sync";
|
||||
|
||||
interface Deferred<T> {
|
||||
promise: Promise<T>;
|
||||
@@ -35,6 +39,7 @@ class TimelineWorld {
|
||||
readonly errors: string[] = [];
|
||||
readonly cursors = new Map<string, { epoch: string; endSeq: number }>();
|
||||
readonly cacheRequests: string[] = [];
|
||||
readonly forcedTimelineTailReplacements = new Set<string>();
|
||||
cacheGate: Deferred<void> | null = null;
|
||||
readonly sync = createViewedTimelineSync({
|
||||
replaceDemandedAgentIds: () => undefined,
|
||||
@@ -73,12 +78,18 @@ class TimelineWorld {
|
||||
this.releaseFetchWaiters();
|
||||
return result.promise;
|
||||
},
|
||||
fetchLatestTail: (agentId) =>
|
||||
this.fetchTimeline(agentId, {
|
||||
direction: "tail",
|
||||
limit: 40,
|
||||
projection: "projected",
|
||||
}),
|
||||
fetchLatestTail: async (agentId) => {
|
||||
this.forcedTimelineTailReplacements.add(agentId);
|
||||
try {
|
||||
return await this.fetchTimeline(agentId, {
|
||||
direction: "tail",
|
||||
limit: 40,
|
||||
projection: "projected",
|
||||
});
|
||||
} finally {
|
||||
this.forcedTimelineTailReplacements.delete(agentId);
|
||||
}
|
||||
},
|
||||
reportError: (error) => {
|
||||
this.errors.push(error instanceof Error ? error.message : String(error));
|
||||
const waiter = this.errorWaiters.shift();
|
||||
@@ -119,6 +130,10 @@ class TimelineWorld {
|
||||
return this.fetches.length;
|
||||
}
|
||||
|
||||
applyTimelineResponse(payload: TimelineResponsePayload): TimelineResponsePayload {
|
||||
return consumeForcedTimelineTailReplacement(payload, this.forcedTimelineTailReplacements);
|
||||
}
|
||||
|
||||
nextCacheRequest(): Promise<string> {
|
||||
const request = this.cacheRequests.at(-1);
|
||||
if (request) return Promise.resolve(request);
|
||||
@@ -273,6 +288,26 @@ test("falls back to the latest tail when a restored cursor has more than one cat
|
||||
|
||||
const fallback = await world.nextFetch("agent-a");
|
||||
expect(fallback.request).toEqual({ direction: "tail", limit: 40, projection: "projected" });
|
||||
expect(
|
||||
world.applyTimelineResponse({
|
||||
requestId: "fallback-tail",
|
||||
agentId: "agent-a",
|
||||
agent: null,
|
||||
direction: "tail",
|
||||
projection: "projected",
|
||||
epoch: "epoch-agent-a",
|
||||
reset: false,
|
||||
staleCursor: false,
|
||||
gap: false,
|
||||
window: { minSeq: 43, maxSeq: 82, nextSeq: 83 },
|
||||
startCursor: { epoch: "epoch-agent-a", seq: 43 },
|
||||
endCursor: { epoch: "epoch-agent-a", seq: 82 },
|
||||
hasOlder: true,
|
||||
hasNewer: false,
|
||||
entries: [],
|
||||
error: null,
|
||||
}).reset,
|
||||
).toBe(true);
|
||||
fallback.respond({ hasNewer: false });
|
||||
});
|
||||
|
||||
@@ -508,27 +543,6 @@ test("manual retries can immediately re-attempt a failed catch-up", async () =>
|
||||
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
|
||||
});
|
||||
|
||||
test("plugin catalog changes reproject visible timelines from the latest tail", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
const membership = await world.nextMembership();
|
||||
membership.succeed();
|
||||
const initial = await world.nextFetch("agent-a");
|
||||
initial.respond({ hasNewer: false });
|
||||
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
|
||||
|
||||
world.sync.reprojectVisibleTimelines();
|
||||
const reprojection = await world.nextFetch("agent-a");
|
||||
|
||||
expect(reprojection.request).toEqual({
|
||||
direction: "tail",
|
||||
limit: 40,
|
||||
projection: "projected",
|
||||
});
|
||||
reprojection.respond({ hasNewer: false });
|
||||
});
|
||||
|
||||
test("redeclaring unchanged visibility does not bypass catch-up backoff", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
|
||||
@@ -26,11 +26,8 @@ import {
|
||||
processTimelineResponse,
|
||||
} from "./session-stream-reducers";
|
||||
import { isTimelineResumeSnapshotAuthoritative } from "./timeline-sync-plan";
|
||||
import { createInstalledTimelineTransform, type TimelineItemTransform } from "@/plugins/timeline";
|
||||
import { replaceWithCanonicalStream } from "@/types/stream";
|
||||
|
||||
const PLUGIN_TIMELINE_REPROJECTION_DELAY_MS = 50;
|
||||
|
||||
export interface TimelineReplicaStorage {
|
||||
readTimeline(serverId: string, agentId: string): Promise<CachedTimeline | undefined>;
|
||||
commitTimeline(serverId: string, agentId: string, timeline: CachedTimeline): void;
|
||||
@@ -93,11 +90,12 @@ async function prepareCachedTimeline(input: {
|
||||
export interface TimelineReplica {
|
||||
prepare(agentId: string): Promise<void>;
|
||||
readCursor(agentId: string): { epoch: string; endSeq: number } | undefined;
|
||||
readRange(agentId: string): AgentTimelineCursorState | undefined;
|
||||
timelineUpdated(agentId: string): void;
|
||||
}
|
||||
|
||||
class TimelineReplicaOwner implements TimelineReplica {
|
||||
private readonly cachedCursors = new Map<string, { epoch: string; endSeq: number }>();
|
||||
private readonly cachedRanges = new Map<string, AgentTimelineCursorState>();
|
||||
private readonly preparations = new Map<string, Promise<void>>();
|
||||
|
||||
constructor(
|
||||
@@ -127,22 +125,24 @@ class TimelineReplicaOwner implements TimelineReplica {
|
||||
});
|
||||
if (!stored) return;
|
||||
if (stored.range) {
|
||||
this.cachedCursors.set(agentId, {
|
||||
epoch: stored.range.epoch,
|
||||
endSeq: stored.range.endSeq,
|
||||
});
|
||||
this.cachedRanges.set(agentId, stored.range);
|
||||
}
|
||||
}
|
||||
|
||||
readCursor(agentId: string): { epoch: string; endSeq: number } | undefined {
|
||||
return this.cachedCursors.get(agentId);
|
||||
const range = this.cachedRanges.get(agentId);
|
||||
return range ? { epoch: range.epoch, endSeq: range.endSeq } : undefined;
|
||||
}
|
||||
|
||||
readRange(agentId: string): AgentTimelineCursorState | undefined {
|
||||
return this.cachedRanges.get(agentId);
|
||||
}
|
||||
|
||||
timelineUpdated(agentId: string): void {
|
||||
const session = useSessionStore.getState().sessions[this.serverId];
|
||||
const timeline = selectAgentTimelineState(session, agentId);
|
||||
if (timeline.status !== "synced") return;
|
||||
this.cachedCursors.delete(agentId);
|
||||
this.cachedRanges.delete(agentId);
|
||||
this.storage.commitTimeline(this.serverId, agentId, {
|
||||
agentId,
|
||||
items: [...timeline.items, ...(session?.agentStreamHead.get(agentId) ?? [])],
|
||||
@@ -170,6 +170,15 @@ export type TimelineResponsePayload = Extract<
|
||||
{ type: "fetch_agent_timeline_response" }
|
||||
>["payload"];
|
||||
|
||||
export function consumeForcedTimelineTailReplacement(
|
||||
payload: TimelineResponsePayload,
|
||||
replacements: Set<string>,
|
||||
): TimelineResponsePayload {
|
||||
if (payload.direction !== "tail") return payload;
|
||||
if (!replacements.delete(payload.agentId)) return payload;
|
||||
return { ...payload, reset: true };
|
||||
}
|
||||
|
||||
function clearAgentInitializingFlag(serverId: string, agentId: string): void {
|
||||
useSessionStore.getState().setInitializingAgents(serverId, (previous) => {
|
||||
if (previous.get(agentId) !== true) return previous;
|
||||
@@ -249,9 +258,9 @@ function finalizeProcessedTimeline(input: {
|
||||
function applyAuthoritativeTimelineResponse(input: {
|
||||
serverId: string;
|
||||
payload: TimelineResponsePayload;
|
||||
cachedCursor?: AgentTimelineCursorState;
|
||||
recoverGap: (agentId: string, cursor: { epoch: string; endSeq: number }) => void;
|
||||
drainQueuedAgentMessage: (agentId: string) => void;
|
||||
transformTimelineItem?: TimelineItemTransform;
|
||||
}): boolean {
|
||||
const { serverId, payload } = input;
|
||||
const agentId = payload.agentId;
|
||||
@@ -259,7 +268,8 @@ function applyAuthoritativeTimelineResponse(input: {
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
const timeline = selectAgentTimelineState(session, agentId);
|
||||
const activeInitDeferred = getInitDeferred(initKey);
|
||||
const currentCursor = timeline.status === "synced" ? (timeline.range ?? undefined) : undefined;
|
||||
const currentCursor =
|
||||
timeline.status === "synced" ? (timeline.range ?? undefined) : input.cachedCursor;
|
||||
const result = processTimelineResponse({
|
||||
payload,
|
||||
currentTail: timeline.status === "cold" ? [] : timeline.items,
|
||||
@@ -269,7 +279,6 @@ function applyAuthoritativeTimelineResponse(input: {
|
||||
hasActiveInitDeferred: Boolean(activeInitDeferred),
|
||||
initRequestDirection: activeInitDeferred?.requestDirection ?? "tail",
|
||||
sendingClientMessageIds: getSendingClientMessageIds(session?.messageSubmissions.get(agentId)),
|
||||
transformTimelineItem: input.transformTimelineItem,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
@@ -320,7 +329,6 @@ export interface ViewedTimelineUiBridge {
|
||||
getAgentTimelineStatus(agentId: string): ViewedTimelineStatus;
|
||||
getAgentTimelineError(agentId: string): string | null;
|
||||
retryVisibleAgentTimeline(agentId: string): void;
|
||||
reprojectVisibleTimelines(): void;
|
||||
}
|
||||
|
||||
export interface ViewedTimelineSync extends ViewedTimelineUiBridge {
|
||||
@@ -349,33 +357,6 @@ export function createViewedTimelineOwner(input: {
|
||||
drainQueuedAgentMessage: (agentId: string) => void;
|
||||
ports: ViewedTimelineOwnerPorts;
|
||||
}): ViewedTimelineOwner {
|
||||
const transformTimelineItem = createInstalledTimelineTransform(input.serverId);
|
||||
const reprojections = new Set<string>();
|
||||
const pendingReprojections = new Set<string>();
|
||||
const scheduledReprojections = new Map<string, () => void>();
|
||||
const startTimelineReprojection = (agentId: string) => {
|
||||
scheduledReprojections.delete(agentId);
|
||||
if (reprojections.has(agentId)) {
|
||||
pendingReprojections.add(agentId);
|
||||
return;
|
||||
}
|
||||
reprojections.add(agentId);
|
||||
void input.ports
|
||||
.fetchLatestTail(agentId)
|
||||
.catch(input.ports.reportError)
|
||||
.finally(() => {
|
||||
reprojections.delete(agentId);
|
||||
if (pendingReprojections.delete(agentId)) reprojectTimeline(agentId);
|
||||
});
|
||||
};
|
||||
const reprojectTimeline = (agentId: string) => {
|
||||
scheduledReprojections.get(agentId)?.();
|
||||
const cancel = input.ports.schedule(
|
||||
() => startTimelineReprojection(agentId),
|
||||
PLUGIN_TIMELINE_REPROJECTION_DELAY_MS,
|
||||
);
|
||||
scheduledReprojections.set(agentId, cancel);
|
||||
};
|
||||
const sync = createViewedTimelineSync({
|
||||
...input.ports,
|
||||
prepare: (agentId) => input.replica.prepare(agentId),
|
||||
@@ -387,9 +368,7 @@ export function createViewedTimelineOwner(input: {
|
||||
setAgentStreamState: (...args) => useSessionStore.getState().setAgentStreamState(...args),
|
||||
setAgentTimelineCursor: (...args) => useSessionStore.getState().setAgentTimelineCursor(...args),
|
||||
recoverTimelineGap: (agentId, cursor) => sync.recoverGap(agentId, cursor),
|
||||
reprojectTimeline,
|
||||
onCommitted: (agentId) => input.replica.timelineUpdated(agentId),
|
||||
transformTimelineItem,
|
||||
});
|
||||
return {
|
||||
...sync,
|
||||
@@ -397,9 +376,9 @@ export function createViewedTimelineOwner(input: {
|
||||
const accepted = applyAuthoritativeTimelineResponse({
|
||||
serverId: input.serverId,
|
||||
payload,
|
||||
cachedCursor: input.replica.readRange(payload.agentId),
|
||||
recoverGap: (agentId, cursor) => sync.recoverGap(agentId, cursor),
|
||||
drainQueuedAgentMessage: input.drainQueuedAgentMessage,
|
||||
transformTimelineItem,
|
||||
});
|
||||
if (accepted) input.replica.timelineUpdated(payload.agentId);
|
||||
},
|
||||
@@ -410,8 +389,6 @@ export function createViewedTimelineOwner(input: {
|
||||
streamQueue.flushAgent(agentId);
|
||||
},
|
||||
dispose() {
|
||||
for (const cancel of scheduledReprojections.values()) cancel();
|
||||
scheduledReprojections.clear();
|
||||
streamQueue.dispose({ flush: true });
|
||||
sync.dispose();
|
||||
},
|
||||
@@ -947,11 +924,5 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
listeners.clear();
|
||||
},
|
||||
retryVisibleAgentTimeline,
|
||||
reprojectVisibleTimelines() {
|
||||
if (!active || !connected) return;
|
||||
for (const agentId of visibleAgentIds()) {
|
||||
void ports.fetchLatestTail(agentId).catch(ports.reportError);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
hydrateStreamState,
|
||||
mergeToolCallDetail,
|
||||
reduceStreamUpdate,
|
||||
streamTimelineItemIdentity,
|
||||
type AgentToolCallItem,
|
||||
type StreamItem,
|
||||
isAgentToolCallItem,
|
||||
@@ -18,9 +19,99 @@ import {
|
||||
import type { AgentProvider, ToolCallDetail } from "@getpaseo/protocol/agent-types";
|
||||
import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages";
|
||||
import { buildToolCallDisplayModel } from "@getpaseo/protocol/tool-call-display";
|
||||
import { timelineItemIdentity } from "@getpaseo/protocol/timeline-identity";
|
||||
|
||||
type CanonicalToolStatus = "running" | "completed" | "failed" | "canceled";
|
||||
|
||||
describe("plugin timeline rows", () => {
|
||||
it("uses the protocol identity format for stream tool and plugin rows", () => {
|
||||
const tool = {
|
||||
kind: "tool_call",
|
||||
id: "tool-row",
|
||||
timestamp: new Date(1),
|
||||
payload: {
|
||||
source: "agent",
|
||||
data: {
|
||||
provider: "codex",
|
||||
callId: "call-1",
|
||||
name: "read",
|
||||
status: "running",
|
||||
error: null,
|
||||
detail: { type: "unknown", input: null, output: null },
|
||||
},
|
||||
},
|
||||
} satisfies StreamItem;
|
||||
const plugin = {
|
||||
kind: "plugin",
|
||||
id: "review/row-1",
|
||||
pluginId: "review",
|
||||
pluginItemId: "row-1",
|
||||
itemKind: "review",
|
||||
version: 1,
|
||||
data: {},
|
||||
timestamp: new Date(1),
|
||||
} satisfies StreamItem;
|
||||
|
||||
expect(streamTimelineItemIdentity(tool)).toBe(
|
||||
timelineItemIdentity({ type: "tool_call", ...tool.payload.data }),
|
||||
);
|
||||
expect(streamTimelineItemIdentity(plugin)).toBe(
|
||||
timelineItemIdentity({
|
||||
type: "plugin",
|
||||
id: plugin.pluginItemId,
|
||||
pluginId: plugin.pluginId,
|
||||
kind: plugin.itemKind,
|
||||
version: plugin.version,
|
||||
data: plugin.data,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("replaces a live row when the plugin-scoped identity repeats", () => {
|
||||
const first = reduceStreamUpdate(
|
||||
[],
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
item: {
|
||||
type: "plugin",
|
||||
id: "review-1",
|
||||
pluginId: "review",
|
||||
kind: "review",
|
||||
version: 1,
|
||||
data: { status: "running" },
|
||||
},
|
||||
},
|
||||
new Date(1),
|
||||
);
|
||||
const second = reduceStreamUpdate(
|
||||
first,
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
item: {
|
||||
type: "plugin",
|
||||
id: "review-1",
|
||||
pluginId: "review",
|
||||
kind: "review",
|
||||
version: 1,
|
||||
data: { status: "complete" },
|
||||
},
|
||||
},
|
||||
new Date(2),
|
||||
);
|
||||
|
||||
expect(second).toHaveLength(1);
|
||||
expect(second[0]).toMatchObject({
|
||||
kind: "plugin",
|
||||
id: "review/review-1",
|
||||
pluginId: "review",
|
||||
pluginItemId: "review-1",
|
||||
data: { status: "complete" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("user message identity", () => {
|
||||
it("replaces provisional optimistic turn membership with canonical membership", () => {
|
||||
const optimistic = createUserMessage({
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import type {
|
||||
AgentProvider,
|
||||
AgentTimelineItem,
|
||||
JsonValue,
|
||||
ToolCallDetail,
|
||||
} from "@getpaseo/protocol/agent-types";
|
||||
import { timelineItemIdentity } from "@getpaseo/protocol/timeline-identity";
|
||||
import type { AgentAttachment, AgentStreamEventPayload } from "@getpaseo/protocol/messages";
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
import { extractTaskEntriesFromToolCall } from "../utils/tool-call-parsers";
|
||||
import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
|
||||
import type { InstalledPluginTimelineItem, TimelineItemTransform } from "@/plugins/timeline";
|
||||
|
||||
/**
|
||||
* Simple hash function for deterministic ID generation
|
||||
@@ -441,11 +442,14 @@ function removeUserMessageAt(items: UserMessageItem[], index: number): UserMessa
|
||||
}
|
||||
|
||||
function mergeRetainedLifecycleItem(tail: StreamItem[], retained: StreamItem): StreamItem[] | null {
|
||||
if (retained.kind === "plugin") {
|
||||
return replaceTimelineIdentityItem(tail, retained);
|
||||
}
|
||||
if (!retained.timelineCursor) {
|
||||
return null;
|
||||
}
|
||||
if (isAgentToolCallItem(retained)) {
|
||||
const tailIndex = findExistingAgentToolCallIndex(tail, retained.payload.data.callId);
|
||||
const tailIndex = findExistingTimelineIdentityIndex(tail, retained.payload.data.callId);
|
||||
const existing = tail[tailIndex];
|
||||
if (tailIndex < 0 || !existing || !isAgentToolCallItem(existing)) {
|
||||
return null;
|
||||
@@ -495,6 +499,20 @@ function mergeRetainedLifecycleItem(tail: StreamItem[], retained: StreamItem): S
|
||||
return null;
|
||||
}
|
||||
|
||||
function replaceTimelineIdentityItem(
|
||||
items: StreamItem[],
|
||||
retained: PluginTimelineStreamItem,
|
||||
): StreamItem[] | null {
|
||||
const identity = streamTimelineItemIdentity(retained);
|
||||
if (identity === null) return null;
|
||||
const index = findExistingTimelineIdentityIndex(items, identity);
|
||||
const existing = items[index];
|
||||
if (index < 0 || !existing || existing.kind !== "plugin") return null;
|
||||
const next = [...items];
|
||||
next[index] = retained;
|
||||
return next;
|
||||
}
|
||||
|
||||
function reconcileReplacementHeadAgainstTail(
|
||||
tail: StreamItem[],
|
||||
retainedHead: StreamItem[],
|
||||
@@ -784,9 +802,10 @@ export interface PluginTimelineStreamItem {
|
||||
turnId?: string;
|
||||
timestamp: Date;
|
||||
pluginId: string;
|
||||
pluginItemId: string;
|
||||
itemKind: string;
|
||||
version: number;
|
||||
data: InstalledPluginTimelineItem["data"];
|
||||
data: JsonValue;
|
||||
}
|
||||
|
||||
export interface TodoEntry {
|
||||
@@ -818,8 +837,6 @@ interface StreamUpdateOptions {
|
||||
source?: StreamUpdateSource;
|
||||
reservedItemIds?: ReadonlySet<string>;
|
||||
timelineCursor?: TimelinePosition;
|
||||
transformTimelineItem?: TimelineItemTransform;
|
||||
transformedTimelineItems?: InstalledPluginTimelineItem[] | null;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
@@ -1004,13 +1021,14 @@ function finalizeActiveThoughts(state: StreamItem[]): StreamItem[] {
|
||||
return mutated ? nextState : state;
|
||||
}
|
||||
|
||||
function findExistingAgentToolCallIndex(state: StreamItem[], callId: string): number {
|
||||
return state.findIndex(
|
||||
(entry) =>
|
||||
entry.kind === "tool_call" &&
|
||||
entry.payload.source === "agent" &&
|
||||
entry.payload.data.callId === callId,
|
||||
);
|
||||
export function streamTimelineItemIdentity(item: StreamItem): string | null {
|
||||
if (isAgentToolCallItem(item)) return item.payload.data.callId;
|
||||
if (item.kind === "plugin") return `${item.pluginId}/${item.pluginItemId}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
function findExistingTimelineIdentityIndex(state: StreamItem[], identity: string): number {
|
||||
return state.findIndex((entry) => streamTimelineItemIdentity(entry) === identity);
|
||||
}
|
||||
|
||||
function hasNonEmptyObject(value: unknown): boolean {
|
||||
@@ -1153,7 +1171,7 @@ function appendAgentToolCall(
|
||||
timestamp: Date,
|
||||
timelineCursor?: TimelinePosition,
|
||||
): StreamItem[] {
|
||||
const existingIndex = findExistingAgentToolCallIndex(state, data.callId);
|
||||
const existingIndex = findExistingTimelineIdentityIndex(state, data.callId);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
const existing = state[existingIndex];
|
||||
@@ -1197,6 +1215,34 @@ function appendAgentToolCall(
|
||||
return [...state, item];
|
||||
}
|
||||
|
||||
function appendPluginTimelineItem(
|
||||
state: StreamItem[],
|
||||
item: Extract<AgentTimelineItem, { type: "plugin" }>,
|
||||
timestamp: Date,
|
||||
timelineCursor?: TimelinePosition,
|
||||
): StreamItem[] {
|
||||
const identity = timelineItemIdentity(item);
|
||||
if (identity === null) return state;
|
||||
const nextItem: PluginTimelineStreamItem = {
|
||||
kind: "plugin",
|
||||
id: identity,
|
||||
pluginId: item.pluginId,
|
||||
pluginItemId: item.id,
|
||||
itemKind: item.kind,
|
||||
version: item.version,
|
||||
data: item.data,
|
||||
timestamp,
|
||||
...(timelineCursor ? { timelineCursor } : {}),
|
||||
};
|
||||
const existingIndex = findExistingTimelineIdentityIndex(state, identity);
|
||||
if (existingIndex < 0) return [...state, nextItem];
|
||||
const existing = state[existingIndex];
|
||||
if (!existing || existing.kind !== "plugin") return state;
|
||||
const next = [...state];
|
||||
next[existingIndex] = { ...nextItem, id: existing.id };
|
||||
return next;
|
||||
}
|
||||
|
||||
function appendActivityLog(state: StreamItem[], entry: ActivityLogItem): StreamItem[] {
|
||||
const index = state.findIndex((existing) => existing.id === entry.id);
|
||||
if (index >= 0) {
|
||||
@@ -1430,33 +1476,8 @@ function reduceTimelineEvent(
|
||||
source: StreamUpdateSource,
|
||||
reservedItemIds?: ReadonlySet<string>,
|
||||
timelineCursor?: TimelinePosition,
|
||||
transformTimelineItem?: TimelineItemTransform,
|
||||
transformedTimelineItems?: InstalledPluginTimelineItem[] | null,
|
||||
): StreamItem[] {
|
||||
const item = event.item;
|
||||
const transformed =
|
||||
transformedTimelineItems === undefined
|
||||
? transformTimelineItem?.(item as AgentTimelineItem)
|
||||
: (transformedTimelineItems ?? undefined);
|
||||
if (transformed !== undefined) {
|
||||
const projected = state.slice();
|
||||
for (const pluginItem of transformed) {
|
||||
const identity = `${pluginItem.pluginId}/${pluginItem.kind}/${pluginItem.version}/${JSON.stringify(pluginItem.data)}`;
|
||||
const streamItem: PluginTimelineStreamItem = {
|
||||
kind: "plugin",
|
||||
id: createUniqueTimelineId(projected, "plugin", identity, timestamp),
|
||||
...(timelineCursor ? { timelineCursor } : {}),
|
||||
...(event.turnId ? { turnId: event.turnId } : {}),
|
||||
timestamp,
|
||||
pluginId: pluginItem.pluginId,
|
||||
itemKind: pluginItem.kind,
|
||||
version: pluginItem.version,
|
||||
data: pluginItem.data,
|
||||
};
|
||||
projected.push(streamItem);
|
||||
}
|
||||
return finalizeActiveThoughts(projected);
|
||||
}
|
||||
switch (item.type) {
|
||||
case "user_message":
|
||||
return finalizeActiveThoughts(
|
||||
@@ -1516,6 +1537,10 @@ function reduceTimelineEvent(
|
||||
return finalizeActiveThoughts(
|
||||
reduceTimelineCompaction(state, item, timestamp, timelineCursor),
|
||||
);
|
||||
case "plugin":
|
||||
return finalizeActiveThoughts(
|
||||
appendPluginTimelineItem(state, item, timestamp, timelineCursor),
|
||||
);
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
@@ -1541,8 +1566,6 @@ export function reduceStreamUpdate(
|
||||
source,
|
||||
options?.reservedItemIds,
|
||||
options?.timelineCursor,
|
||||
options?.transformTimelineItem,
|
||||
options?.transformedTimelineItems,
|
||||
),
|
||||
event,
|
||||
);
|
||||
@@ -1614,7 +1637,7 @@ export function hydrateStreamState(
|
||||
timestamp: Date;
|
||||
timelineCursor?: TimelinePosition;
|
||||
}>,
|
||||
options?: Pick<StreamUpdateOptions, "source" | "reservedItemIds" | "transformTimelineItem">,
|
||||
options?: Pick<StreamUpdateOptions, "source" | "reservedItemIds">,
|
||||
): StreamItem[] {
|
||||
const hydrated = events.reduce<StreamItem[]>((state, { event, timestamp, timelineCursor }) => {
|
||||
return reduceStreamUpdate(state, event, timestamp, { ...options, timelineCursor });
|
||||
@@ -1654,14 +1677,10 @@ function applyCompletionToTail(
|
||||
/**
|
||||
* Determine what kind of StreamItem an event would produce
|
||||
*/
|
||||
function getEventItemKind(
|
||||
event: AgentStreamEventPayload,
|
||||
transformedTimelineItems?: InstalledPluginTimelineItem[],
|
||||
): StreamItem["kind"] | null {
|
||||
function getEventItemKind(event: AgentStreamEventPayload): StreamItem["kind"] | null {
|
||||
if (event.type !== "timeline") {
|
||||
return null;
|
||||
}
|
||||
if (transformedTimelineItems !== undefined) return "plugin";
|
||||
switch (event.item.type) {
|
||||
case "user_message":
|
||||
return "user_message";
|
||||
@@ -1675,6 +1694,8 @@ function getEventItemKind(
|
||||
return "todo_list";
|
||||
case "error":
|
||||
return "activity_log";
|
||||
case "plugin":
|
||||
return "plugin";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -1968,14 +1989,6 @@ function applyCanonicalUserMessageEvent(params: {
|
||||
* - Non-streamable items flush head to tail first, then go to tail
|
||||
* - Turn completion events flush head to tail
|
||||
*/
|
||||
function transformEventTimelineItems(
|
||||
event: AgentStreamEventPayload,
|
||||
transformTimelineItem: TimelineItemTransform | undefined,
|
||||
): InstalledPluginTimelineItem[] | undefined {
|
||||
if (event.type !== "timeline") return undefined;
|
||||
return transformTimelineItem?.(event.item);
|
||||
}
|
||||
|
||||
export function applyStreamEvent(params: {
|
||||
tail: StreamItem[];
|
||||
head: StreamItem[];
|
||||
@@ -1984,21 +1997,16 @@ export function applyStreamEvent(params: {
|
||||
source?: StreamUpdateSource;
|
||||
timelineCursor?: TimelinePosition;
|
||||
unmatchedUserMessageInsert?: "tail" | "head";
|
||||
transformTimelineItem?: TimelineItemTransform;
|
||||
}): ApplyStreamEventResult {
|
||||
const { tail, head, event, timestamp } = params;
|
||||
const transformedTimelineItems = transformEventTimelineItems(event, params.transformTimelineItem);
|
||||
const canonicalUserResult =
|
||||
transformedTimelineItems === undefined
|
||||
? applyCanonicalUserMessageEvent({
|
||||
tail,
|
||||
head,
|
||||
event,
|
||||
timestamp,
|
||||
timelineCursor: params.timelineCursor,
|
||||
unmatchedInsert: params.unmatchedUserMessageInsert,
|
||||
})
|
||||
: null;
|
||||
const canonicalUserResult = applyCanonicalUserMessageEvent({
|
||||
tail,
|
||||
head,
|
||||
event,
|
||||
timestamp,
|
||||
timelineCursor: params.timelineCursor,
|
||||
unmatchedInsert: params.unmatchedUserMessageInsert,
|
||||
});
|
||||
if (canonicalUserResult) return canonicalUserResult;
|
||||
const source = params.source ?? "live";
|
||||
let nextTail = tail;
|
||||
@@ -2029,7 +2037,7 @@ export function applyStreamEvent(params: {
|
||||
return { tail: nextTail, head: nextHead, changedTail, changedHead };
|
||||
}
|
||||
|
||||
const incomingKind = getEventItemKind(event, transformedTimelineItems);
|
||||
const incomingKind = getEventItemKind(event);
|
||||
|
||||
// Check if we need to flush head before processing this event
|
||||
if (
|
||||
@@ -2071,8 +2079,6 @@ export function applyStreamEvent(params: {
|
||||
source,
|
||||
reservedItemIds,
|
||||
timelineCursor: params.timelineCursor,
|
||||
transformTimelineItem: params.transformTimelineItem,
|
||||
transformedTimelineItems: transformedTimelineItems ?? null,
|
||||
});
|
||||
if (reduced !== nextHead) {
|
||||
nextHead = reduced;
|
||||
@@ -2095,8 +2101,6 @@ export function applyStreamEvent(params: {
|
||||
const reduced = reduceStreamUpdate(nextTail, event, timestamp, {
|
||||
source,
|
||||
timelineCursor: params.timelineCursor,
|
||||
transformTimelineItem: params.transformTimelineItem,
|
||||
transformedTimelineItems: transformedTimelineItems ?? null,
|
||||
});
|
||||
if (reduced !== nextTail) {
|
||||
nextTail = reduced;
|
||||
|
||||
@@ -2883,6 +2883,19 @@ export class DaemonClient {
|
||||
return payload;
|
||||
}
|
||||
|
||||
async appendAgentTimelineItem(
|
||||
agentId: string,
|
||||
item: Omit<import("@getpaseo/protocol/agent-types").PluginTimelineItem, "pluginId">,
|
||||
): Promise<{ seq: number; epoch: string }> {
|
||||
const requestId = this.createRequestId();
|
||||
const payload = await this.sendCorrelatedSessionRequest({
|
||||
requestId,
|
||||
message: { type: "agent.timeline.append.request", requestId, agentId, item },
|
||||
responseType: "agent.timeline.append.response",
|
||||
});
|
||||
return { seq: payload.seq, epoch: payload.epoch };
|
||||
}
|
||||
|
||||
async listAgentTimelinePrompts(
|
||||
agentId: string,
|
||||
options: { requestId?: string; timeout?: number } = {},
|
||||
|
||||
@@ -737,6 +737,27 @@ test("agent handles delegate create, send, timeline refetch, archive, and local
|
||||
await timelinePromise;
|
||||
expect(agent.current()).toEqual(timelineAgent);
|
||||
|
||||
const appendPromise = agent.timeline.append({
|
||||
type: "plugin",
|
||||
id: "review-1",
|
||||
kind: "review",
|
||||
version: 1,
|
||||
data: { status: "running" },
|
||||
});
|
||||
const appendRequest = parseSentSessionMessage(ws.sent.at(-1));
|
||||
expect(appendRequest).toMatchObject({
|
||||
type: "agent.timeline.append.request",
|
||||
agentId: "agent_sdk",
|
||||
item: { id: "review-1", kind: "review" },
|
||||
});
|
||||
ws.message(
|
||||
sessionMessage({
|
||||
type: "agent.timeline.append.response",
|
||||
payload: { requestId: appendRequest.requestId, seq: 8, epoch: "epoch-sdk" },
|
||||
}),
|
||||
);
|
||||
await expect(appendPromise).resolves.toEqual({ seq: 8, epoch: "epoch-sdk" });
|
||||
|
||||
const archivePromise = agent.archive();
|
||||
const archiveRequest = parseSentSessionMessage(ws.sent.at(-1));
|
||||
expect(archiveRequest).toMatchObject({
|
||||
|
||||
@@ -24,6 +24,7 @@ import type {
|
||||
WorkspaceCreateRequest,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import { DaemonClient } from "./daemon-client.js";
|
||||
import type { PluginTimelineItem } from "@getpaseo/protocol/agent-types";
|
||||
import type {
|
||||
FetchAgentsEntry,
|
||||
FetchAgentsOptions,
|
||||
@@ -251,6 +252,7 @@ export type PaseoAgentStream = Extract<SessionOutboundMessage, { type: "agent_st
|
||||
export type PaseoAgentUpdateHandler = (update: PaseoAgentUpdate) => void;
|
||||
|
||||
export interface PaseoAgentTimelineHandle {
|
||||
append(item: Omit<PluginTimelineItem, "pluginId">): Promise<{ seq: number; epoch: string }>;
|
||||
/**
|
||||
* Fetches a fresh timeline page through the existing daemon RPC. If the daemon
|
||||
* includes an agent snapshot in the response, the parent handle is updated to
|
||||
@@ -606,6 +608,7 @@ function createAgentHandleFactory(daemonClient: DaemonClient): AgentHandleFactor
|
||||
const handle: PaseoAgentHandle = {
|
||||
id,
|
||||
timeline: {
|
||||
append: (item) => daemonClient.appendAgentTimelineItem(id, item),
|
||||
refetch: async (options) => {
|
||||
const result = await daemonClient.fetchAgentTimeline(id, options);
|
||||
if (result.agent) {
|
||||
|
||||
@@ -822,7 +822,7 @@ async function runRegression({ page, client, serverId, targetUrl, callerAgentId,
|
||||
await originalDeck.getByRole("button", { name: "Cancel element selector" }).click();
|
||||
|
||||
await originalDeck.getByTestId(`workspace-tab-agent_${callerAgentId}`).click();
|
||||
await page.getByRole("button", { name: "Open command center" }).click();
|
||||
await page.getByTestId("sidebar-search").click();
|
||||
await page.getByTestId("command-center-input").fill("Split pane right");
|
||||
await page.getByText("Split pane right", { exact: true }).click();
|
||||
assert(
|
||||
|
||||
@@ -25,6 +25,17 @@ describe("cli-install-path", () => {
|
||||
).toBe("/home/user/Applications/Paseo.AppImage");
|
||||
});
|
||||
|
||||
it("uses the bundled shim for packaged linux installs outside an AppImage", () => {
|
||||
expect(
|
||||
resolveCliInstallSourcePath({
|
||||
platform: "linux",
|
||||
isPackaged: true,
|
||||
executablePath: "/opt/Paseo/Paseo",
|
||||
shimPath: "/opt/Paseo/resources/bin/paseo",
|
||||
}),
|
||||
).toBe("/opt/Paseo/resources/bin/paseo");
|
||||
});
|
||||
|
||||
it("falls back to the shim on windows and in development", () => {
|
||||
expect(
|
||||
resolveCliInstallSourcePath({
|
||||
|
||||
@@ -22,6 +22,7 @@ export function resolveCliInstallSourcePath(input: {
|
||||
if (appImagePath) {
|
||||
return appImagePath;
|
||||
}
|
||||
return input.shimPath;
|
||||
}
|
||||
|
||||
return input.executablePath;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ComponentType } from "react";
|
||||
import type { PaseoApi } from "@getpaseo/client";
|
||||
import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types";
|
||||
import type { AgentTimelineItem, JsonValue } from "@getpaseo/protocol/agent-types";
|
||||
import type { ZodType, input as ZodInput, output as ZodOutput } from "zod";
|
||||
import type { PluginRpcContract } from "./rpc.js";
|
||||
|
||||
@@ -182,16 +182,11 @@ export interface PluginAttachmentSourceContribution {
|
||||
search: PluginRpcContract;
|
||||
}
|
||||
|
||||
export type PluginTimelineData =
|
||||
| null
|
||||
| boolean
|
||||
| number
|
||||
| string
|
||||
| PluginTimelineData[]
|
||||
| { [key: string]: PluginTimelineData };
|
||||
export type PluginTimelineData = JsonValue;
|
||||
|
||||
export interface PluginTimelineItem {
|
||||
type: "plugin";
|
||||
id?: string;
|
||||
kind: string;
|
||||
version: number;
|
||||
data: PluginTimelineData;
|
||||
@@ -211,6 +206,7 @@ export type PluginTimelineTransformerContribution<
|
||||
};
|
||||
transform(input: {
|
||||
item: Extract<AgentTimelineItem, { type: ItemType }>;
|
||||
phase: "streaming" | "complete";
|
||||
}): PluginTimelineTransformResult | undefined;
|
||||
}
|
||||
: never;
|
||||
@@ -280,6 +276,22 @@ export type PluginCommandCenterItemContribution =
|
||||
onSelect(context: PluginAgentCommandContext): void | Promise<void>;
|
||||
});
|
||||
|
||||
interface PluginClientSlashCommandBase {
|
||||
name: string;
|
||||
description: string;
|
||||
argumentHint: string;
|
||||
}
|
||||
|
||||
export type PluginClientSlashCommandContribution =
|
||||
| (PluginClientSlashCommandBase & {
|
||||
context: "workspace";
|
||||
onSubmit(context: PluginWorkspaceCommandContext & { args: string }): void | Promise<void>;
|
||||
})
|
||||
| (PluginClientSlashCommandBase & {
|
||||
context: "agent";
|
||||
onSubmit(context: PluginAgentCommandContext & { args: string }): void | Promise<void>;
|
||||
});
|
||||
|
||||
export interface PluginHandlerContext {
|
||||
paseo: PaseoApi;
|
||||
}
|
||||
@@ -296,6 +308,7 @@ export interface PluginContext {
|
||||
addSidebarItem(contribution: PluginSidebarContribution): void;
|
||||
addWorkspacePanel(contribution: PluginWorkspacePanelContribution): void;
|
||||
addCommandCenterItem(contribution: PluginCommandCenterItemContribution): void;
|
||||
addClientSlashCommand(contribution: PluginClientSlashCommandContribution): void;
|
||||
addClientSide(contribution: PluginClientContribution): void;
|
||||
addAttachmentSource(contribution: PluginAttachmentSourceContribution): void;
|
||||
addTheme(contribution: PluginThemeContribution): void;
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
PluginAttachmentSourceContribution,
|
||||
PluginCommandCenterItemContribution,
|
||||
PluginClientContribution,
|
||||
PluginClientSlashCommandContribution,
|
||||
PluginContext,
|
||||
PluginSidebarContribution,
|
||||
PluginSurfaceContribution,
|
||||
@@ -27,6 +28,7 @@ interface PluginCollector {
|
||||
addSidebarItem(contribution: PluginSidebarContribution): void;
|
||||
addWorkspacePanel(contribution: PluginWorkspacePanelContribution): void;
|
||||
addCommandCenterItem(contribution: PluginCommandCenterItemContribution): void;
|
||||
addClientSlashCommand(contribution: PluginClientSlashCommandContribution): void;
|
||||
addClientSide(contribution: PluginClientContribution): void;
|
||||
addAttachmentSource(contribution: PluginAttachmentSourceContribution): void;
|
||||
addTheme(contribution: PluginThemeContribution): void;
|
||||
@@ -39,6 +41,7 @@ export interface PluginRegistrationCollector {
|
||||
sidebarItems: PluginSidebarContribution[];
|
||||
workspacePanels: PluginWorkspacePanelContribution[];
|
||||
commandCenterItems: PluginCommandCenterItemContribution[];
|
||||
clientSlashCommands: PluginClientSlashCommandContribution[];
|
||||
clientSide: PluginClientContribution | null;
|
||||
attachmentSources: PluginAttachmentSourceContribution[];
|
||||
themes: PluginThemeContribution[];
|
||||
@@ -54,6 +57,7 @@ export function createPluginContext(
|
||||
| "addSidebarItem"
|
||||
| "addWorkspacePanel"
|
||||
| "addCommandCenterItem"
|
||||
| "addClientSlashCommand"
|
||||
| "addClientSide"
|
||||
| "addAttachmentSource"
|
||||
| "addTheme"
|
||||
@@ -73,6 +77,9 @@ export function createPluginContext(
|
||||
addCommandCenterItem(contribution) {
|
||||
collector.addCommandCenterItem(contribution);
|
||||
},
|
||||
addClientSlashCommand(contribution) {
|
||||
collector.addClientSlashCommand(contribution);
|
||||
},
|
||||
addClientSide(contribution) {
|
||||
collector.addClientSide(contribution);
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user