Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
175 changes: 175 additions & 0 deletions apps/desktop/docs/SIDEBAR_ACTIVITY_SORT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
# Sidebar "Last updated" sort: activity signal design

Status: shipped with the re-land of the sidebar filter/sort feature
(`reland-sidebar-sort-filter`, reapplying #5956 after revert #5996).
Covers what signal drives the "Last updated" sort, why, the current
polling implementation, and the alternatives considered — including the
ones that are "more correct" and what it would take to move to them.

## The problem

The dashboard sidebar offers three sort modes for projects and their
workspaces: manual, date created, and last updated. The first shipped
version of "Last updated" sorted on `v2_workspaces.updatedAt` and had
two defects, one crash and one semantic:

1. **Crash**: rows reaching the sidebar through the Electric cloud
fallback carry ISO **strings** in `updatedAt`/`createdAt` at runtime
(the Electric client's default parser handles int/bool/float but not
`timestamptz`, and we pass no custom `parser`), while the Drizzle
`SelectV2Workspace` type claims `Date`. `workspace.updatedAt.getTime()`
threw and took the whole sidebar down for anyone with remote
workspaces. Host-served rows are unaffected (superjson revives real
`Date`s over tRPC).
2. **Semantics**: the host only writes `v2_workspaces.updatedAt` on
metadata patches — rename, PR linkage, branch changes. Sending an
agent a message never touches it, so the one thing "Last updated"
obviously should respond to didn't reorder anything. This is what got
the feature reverted.

## What "activity" means now

The host-service already tracks exactly the signal we want. Every agent
hook event POSTs to `notifications.hook`, which normalizes the event
(`UserPromptSubmit`/`PostToolUse` → `Start`, `Stop`/turn-complete →
`Stop`, `PreToolUse`/`Notification` → `PermissionRequest`, session
start/end → `Attached`/`Detached`, agent-agnostic across
Claude/Codex/OpenCode/Grok) and records it on the terminal's agent
binding as `lastEventAt` (epoch ms, persisted in the host's SQLite via
`terminal-agents/persistence.ts`, so it survives host restarts). The
same feed drives the sidebar working/idle status dots.

A workspace's effective activity timestamp is:

```
max(updatedAt, newest lastEventAt across the workspace's agent bindings)
```

so metadata updates still count, and both directions of a conversation
count (prompt submit bumps immediately; tool use and turn completion
keep bumping). Things that intentionally do **not** count: plain
(non-agent) terminal typing, and merely viewing a workspace.

Sorting applies the effective timestamp at every level, stably
(name → id tie-breaks), with NaN-safe coercion for the string-date rows:

- workspaces within a project and within sections
(`sortDashboardSidebarProjectChildren`),
- sections rank among loose workspaces by their newest member,
- the local main workspace stays pinned first,
- projects rank by their most-active workspace
(`getProjectActivityTimestamp`).

## Current implementation: per-host polling

`useDashboardSidebarData` reuses the pull-request query targets (one
entry per reachable host, local or relay-routed) and runs one
react-query per host against `terminalAgents.list`:

- `enabled` only while the sort mode is `"updated"` — manual/created
users generate zero extra traffic;
- `refetchInterval: 10_000`;
- results collapse into `Map<workspaceId, newest lastEventAt>`,
JSON-fingerprinted for stable identity, then threaded onto each
`DashboardSidebarWorkspace` as `lastAgentActivityAt` by the builders.

Properties:

- **Latency**: 0–10s from agent event to reorder. Measured live: a
synthetic `UserPromptSubmit` moved an idle workspace to the top of its
project in ~4s.
- **Cost**: one cheap host round-trip per host per 10s, only while the
mode is active and the window focused (react-query pauses intervals in
background, refetches on focus).
- **Failure mode**: an unreachable host contributes nothing; its
workspaces fall back to `updatedAt`. Nothing crashes, order degrades
gracefully.

Why polling first: it's stateless, self-healing by construction, and
identical in shape to the adjacent PR-status queries, so it was the
lowest-risk way to re-land a previously-reverted feature. The latency is
its only real cost.

## Alternative 1 (recommended next): event-bus push + slow-poll self-heal

The renderer already holds a WebSocket to each host's event bus (it
powers the status dots), `notifications.hook` broadcasts
`agent:lifecycle` on that bus *before* it writes the store, and the bus
client supports wildcard subscriptions — `bus.on("agent:lifecycle", "*",
cb)` receives events for every workspace on the host over the existing
connection.

Design:

- one `"*"` listener per reachable host; on event, patch the activity
map in place via `queryClient.setQueryData` (the payload carries
`workspaceId` + `occurredAt` — no refetch round trip needed);
- keep the poll as reconciliation at a long interval (~60s) plus the
default refetch-on-focus, to catch events missed while a WS was down
(host restart, laptop sleep). Pure push with no reconciliation drifts
silently — `useTerminalAgentBindings` documents exactly this failure
mode and uses the same event+staleTime hybrid one level down.

Result: effectively instant reordering with *less* steady-state traffic
than the 10s poll. Contained change inside `useDashboardSidebarData`.
The only reason it isn't in the initial re-land is sequencing: land the
proven-simple version first.

## Alternative 2 (most correct long-term): persist activity to the cloud

Both approaches above only rank hosts you can currently reach. The
fully correct model is server-side: the host (already the writer of
workspace rows) debounce-bumps a `lastActivityAt` column on
`v2_workspaces` (or a small activity table) on agent events, and
Electric pushes it to every device reactively — this is arguably what
`updatedAt` should have meant all along.

Gains: cross-device correctness (see that your desktop at home is busy,
from your laptop), offline-host ranking from last-known cloud state, no
renderer fan-out at all.

Costs / open questions, which is why it's deferred:

- write volume: agent events are chatty; needs debouncing (e.g. ≥30s
per workspace) and an answer for hosts that are offline mid-burst;
- schema + API surface: a migration, host write path, and backfill;
- it must *not* be conflated with `updatedAt`, or every activity bump
looks like a metadata edit to other consumers;
- the Electric string-date issue applies to any new timestamp column:
either fix the missing `parser` in our shape options first, or the new
column arrives as strings too and every consumer needs coercion.

If/when this lands, the renderer side collapses: the sort reads
`max(updatedAt, lastActivityAt)` straight off the row and both the poll
and the push wiring get deleted.

## Alternatives rejected

- **`chat_sessions.lastActiveAt`** (cloud table, Electric-synced, has
`v2WorkspaceId`): only chat panes write it. Terminal agent panes — the
dominant way people talk to Claude in v2 — never touch it.
- **Local interaction tracking** (bump a localStorage timestamp when the
user opens/messages a workspace): per-device only, misses the
"agent finished while I was elsewhere" bump entirely, and adds a
second source of truth next to the host's binding store.
- **Faster polling**: trivially shrinks latency but scales traffic
inversely and still isn't instant; strictly dominated by push.

## Known limitations of the shipped version

- 0–10s reorder latency (see Alternative 1).
- Unreachable hosts fall back to `updatedAt` ranking.
- Pinned rows keep pin order — the Pinned section ignores sort modes by
design.
- A messaged workspace ranks by newest event, it does not pin to #1: an
agent actively working elsewhere (its `PostToolUse` events keep
bumping) can legitimately out-rank it moments later.
- The `SelectV2Workspace` string-vs-`Date` type lie is still only
patched at the sidebar entry points (`mergeHostWorkspaces`
normalization + NaN-safe `toTime` in the sort). The root fix — an
Electric `parser` for timestamp columns in the shared shape options —
is intentionally out of scope here because it changes runtime types
for every consumer of every Electric collection and needs its own
audited PR. Note that persisted IndexedDB snapshots written before
that fix will still contain strings, so defensive coercion in sort
paths stays necessary regardless.
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { describe, expect, it } from "bun:test";
import type { SelectV2Workspace } from "@superset/db/schema";
import {
type HostWorkspaceRow,
type HostWorkspacesQueryTarget,
mergeHostWorkspaces,
} from "./useHostWorkspaces.utils";

function makeTarget(machineId: string): HostWorkspacesQueryTarget {
return {
machineId,
organizationId: "org-1",
hostUrl: `http://host/${machineId}`,
isLocal: false,
};
}

function makeHostRow(overrides: Partial<HostWorkspaceRow>): HostWorkspaceRow {
return {
id: "w-host",
organizationId: "org-1",
projectId: "p-1",
hostId: "host-a",
name: "host workspace",
branch: "main",
type: "worktree",
createdByUserId: null,
taskId: null,
createdAt: new Date("2026-07-01"),
updatedAt: new Date("2026-07-02"),
worktreePath: "/tmp/w",
worktreeExists: true,
...overrides,
};
}

// Electric rows carry ISO strings at runtime (the shape parser doesn't
// handle timestamptz), despite the SelectV2Workspace type saying Date.
function makeCloudRow(
overrides: Partial<Record<keyof SelectV2Workspace, unknown>>,
): SelectV2Workspace {
return {
id: "w-cloud",
organizationId: "org-1",
projectId: "p-1",
hostId: "host-b",
name: "cloud workspace",
branch: "main",
type: "worktree",
createdByUserId: null,
taskId: null,
createdAt: "2026-06-01T00:00:00.000Z",
updatedAt: "2026-06-02T00:00:00.000Z",
...overrides,
} as SelectV2Workspace;
}

describe("mergeHostWorkspaces", () => {
it("converts cloud-fallback rows' string timestamps into real Dates", () => {
const merged = mergeHostWorkspaces({
hostResults: [],
cloudRows: [makeCloudRow({})],
});

expect(merged).toHaveLength(1);
const row = merged[0];
expect(row?.source).toBe("cloud");
expect(row?.createdAt).toBeInstanceOf(Date);
expect(row?.updatedAt).toBeInstanceOf(Date);
expect(row?.createdAt.getTime()).toBe(
new Date("2026-06-01T00:00:00.000Z").getTime(),
);
expect(row?.updatedAt.getTime()).toBe(
new Date("2026-06-02T00:00:00.000Z").getTime(),
);
});

it("keeps host-served rows authoritative over cloud rows for the same host", () => {
const target = makeTarget("host-a");
const merged = mergeHostWorkspaces({
hostResults: [
{
target,
rows: [makeHostRow({ id: "w-1", hostId: "host-a" })],
reachable: true,
},
],
cloudRows: [
makeCloudRow({ id: "w-stale", hostId: "host-a" }),
makeCloudRow({ id: "w-other", hostId: "host-b" }),
],
});

expect(merged.map((row) => row.id).sort()).toEqual(["w-1", "w-other"]);
const other = merged.find((row) => row.id === "w-other");
expect(other?.updatedAt).toBeInstanceOf(Date);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,10 @@ export function mergeHostWorkspaces({
seenIds.add(row.id);
items.push({
...row,
// Electric rows carry ISO strings at runtime (the shape parser
// doesn't handle timestamptz), despite the SelectV2Workspace type.
createdAt: new Date(row.createdAt),
updatedAt: new Date(row.updatedAt),
hostReachable: false,
source: "cloud",
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
DEFAULT_V2_USER_PREFERENCES,
type LinkAction,
type LinkTierMap,
type SidebarProjectSortMode,
V2_USER_PREFERENCES_ID,
type V2UserPreferencesRow,
} from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema";
Expand All @@ -24,6 +25,7 @@ export interface V2UserPreferencesApi {
setDeleteLocalBranch: (next: boolean) => void;
setShowPresetsBar: (next: boolean | ((prev: boolean) => boolean)) => void;
toggleShowPresetsBar: () => void;
setSidebarProjectSortMode: (next: SidebarProjectSortMode) => void;
}

export function useV2UserPreferences(): V2UserPreferencesApi {
Expand Down Expand Up @@ -200,6 +202,25 @@ export function useV2UserPreferences(): V2UserPreferencesApi {
setShowPresetsBar((prev) => !prev);
}, [setShowPresetsBar]);

const setSidebarProjectSortMode = useCallback(
(next: SidebarProjectSortMode) => {
const existing = collections.v2UserPreferences.get(
V2_USER_PREFERENCES_ID,
);
if (!existing) {
collections.v2UserPreferences.insert({
...DEFAULT_V2_USER_PREFERENCES,
sidebarProjectSortMode: next,
});
return;
Comment on lines +205 to +215

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C4 'useLiveQuery\(' apps/desktop/src/renderer
rg -n -C5 '\b(isReady|isLoading)\b' apps/desktop/src/renderer
rg -n -C5 'setSidebarProjectSortMode|v2UserPreferences\.(insert|update)' apps/desktop/src/renderer

Repository: superset-sh/superset

Length of output: 50377


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== target hook outline/size =="
wc -l apps/desktop/src/renderer/hooks/useV2UserPreferences/useV2UserPreferences.ts
ast-grep outline apps/desktop/src/renderer/hooks/useV2UserPreferences/useV2UserPreferences.ts || true

echo "== target file relevant sections =="
sed -n '1,260p' apps/desktop/src/renderer/hooks使用V2UserPreferences/useV2UserPreferences.ts | cat -n 2>/dev/null || true

echo "== all occurrences of useV2UserPreferences =="
rg -n -C3 'useV2UserPreferences|useV2Project' apps/desktop/src/renderer | head -n 200

Repository: superset-sh/superset

Length of output: 29475


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== target file relevant sections =="
sed -n '1,238p' apps/desktop/src/renderer/hooks/useV2UserPreferences/useV2UserPreferences.ts | cat -n

echo "== package versions =="
node - <<'JS'
const fs = require('fs');
for (const f of ['package.json','apps/desktop/package.json']) {
  if (!fs.existsSync(f)) continue;
  const p = JSON.parse(fs.readFileSync(f,'utf8'));
  console.log(f, {
    tanstackReactDb: (p.dependencies?.['`@tanstack/react-db`'] || p.devDependencies?.['`@tanstack/react-db`']) &&
      p.dependencies?.['`@tanstack/react-db`'] || p.devDependencies?.['`@tanstack/react-db`']
  });
}
JS

echo "== nearby hook live query consumers (narrow) =="
rg -n -C2 'setSidebarProjectSortMode|useV2UserPreferences\s*\(\)' \
  apps/desktop/src/renderer/routes/_authenticated/_dashboard/components \
  apps/desktop/src/renderer/routes/_authenticated/_dashboard \
  apps/desktop/src/renderer/screens \
  | head -n 220

Repository: superset-sh/superset

Length of output: 21665


🌐 Web query:

@tanstack/react-db useLiveQuery isReady isLoading hydration cache data

💡 Result:

In the context of the TanStack ecosystem, @tanstack/react-db and @tanstack/react-query serve different primary purposes, though TanStack DB is built on top of TanStack Query [1]. useLiveQuery is a hook provided by @tanstack/react-db for managing reactive, database-like collections [2][3][1]. It automatically re-runs when the underlying data in a collection changes, eliminating the need for manual cache invalidation [1]. Key concepts regarding your query include: Loading States (isLoading) In useLiveQuery, the isLoading property is a boolean that indicates whether the query is currently performing its initial fetch [3]. Like TanStack Query's useQuery, this state is typically used to render loading indicators [2][3]. Hydration and Cache Because TanStack DB uses TanStack Query's caching and synchronization mechanisms under the hood, it interacts with the same underlying infrastructure [1]. 1. Hydration: This is the process of transferring prefetched state from the server to the client [4][5]. It uses the same hydrate and dehydrate utilities as TanStack Query to restore state into the QueryClient cache [4][5]. 2. Cache: Data fetched by useLiveQuery is stored in the TanStack Query cache [1]. When hydrating, TanStack Query intelligently merges this state with any existing data in the cache based on timestamps [4][5]. isReady There is no standard isReady property in the primary TanStack Query or TanStack DB hook return objects. If you are seeing this, it may be a custom implementation, a legacy property, or a misidentification of states like isPending or isSuccess [6]. The core statuses used by the TanStack ecosystem to determine if data is available are isPending, isError, and isSuccess [6]. Compatibility Note If you are migrating from @tanstack/react-query to @tanstack/react-db, note that while they share concepts, the APIs differ [7]. useLiveQuery replaces the standard useQuery pattern for collection management [7]. When dealing with SSR and hydration, ensure you are prefetching data on the server and using the HydrationBoundary or hydrate utilities on the client to avoid state mismatches [8][9][4]. Top Results: [2], [3], [1], [4], [5]

Citations:


🌐 Web query:

docs TanStack DB useLiveQuery API return { data, isReady isLoading }

💡 Result:

Yes, the useLiveQuery hook in TanStack DB returns an object that includes the properties { data, isReady, isLoading }, among others [1][2][3]. Specifically, the hook returns an object with the following shape [1][2]: - data: The result of the query. Its type is InferResultType (or an array, depending on the query) and it can be undefined if the query is not yet ready or is disabled [4][1][5]. - isLoading: A boolean indicating if the query is in a loading state [1][2][3]. - isReady: A boolean indicating if the query has finished its initial load and is ready to display [1][2]. In addition to these, the returned object typically includes several other useful properties for managing query state [1][2]: - status: A string representing the current status of the query (e.g., 'loading', 'error', 'success', 'disabled') [1][2][3]. - isError: A boolean indicating if the query encountered an error [1][2]. - isIdle: A boolean indicating if the query is idle [1][2]. - isCleanedUp: A boolean indicating if the query has been cleaned up [1][2]. - collection: The collection object associated with the query [1][2]. - state: A Map containing the reactive state of the query [1][2]. You can destructure these properties directly from the result of the hook [2][3]. Returning undefined or null from the useLiveQuery callback function will disable the query, in which case the status will be 'disabled' and data will be undefined [2][3].

Citations:


Wait for strict live-query readiness before writing user preferences.

useLiveQuery is only destructured for data, so isReady is ignored when collections.v2UserPreferences.get(...) returns no row while hydration is in progress. In that state, setSidebarProjectSortMode can insert a default preference before the persisted row is available. Gate the insert on isReady (and otherwise defer the write) so a concurrent server/provided row cannot collide or be lost.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/src/renderer/hooks/useV2UserPreferences/useV2UserPreferences.ts`
around lines 205 - 215, The setSidebarProjectSortMode callback currently inserts
DEFAULT_V2_USER_PREFERENCES when the live query has not hydrated. Destructure
and use isReady from useLiveQuery, allowing the insert only when readiness is
confirmed; otherwise defer the write while preserving the existing update path
for an available preference row.

Source: Coding guidelines

}
collections.v2UserPreferences.update(V2_USER_PREFERENCES_ID, (draft) => {
draft.sidebarProjectSortMode = next;
});
},
[collections],
);

return {
preferences,
setFileLinks,
Expand All @@ -212,5 +233,6 @@ export function useV2UserPreferences(): V2UserPreferencesApi {
setDeleteLocalBranch,
setShowPresetsBar,
toggleShowPresetsBar,
setSidebarProjectSortMode,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ function describeRunError(error: string): string {
const STATUS_DOT: Record<SelectAutomationRun["status"], string> = {
dispatched: "bg-emerald-500",
dispatching: "bg-amber-500",
skipped_offline: "bg-red-500",
dispatch_failed: "bg-red-500",
skipped_offline: "bg-destructive",
dispatch_failed: "bg-destructive",
};

interface PreviousRunsListProps {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,8 @@ const LAST_RUN_META: Record<
> = {
dispatched: { dot: "bg-emerald-500", label: "ran" },
dispatching: { dot: "bg-amber-500", label: "running" },
skipped_offline: { dot: "bg-red-500", label: "failed", failed: true },
dispatch_failed: { dot: "bg-red-500", label: "failed", failed: true },
skipped_offline: { dot: "bg-destructive", label: "failed", failed: true },
dispatch_failed: { dot: "bg-destructive", label: "failed", failed: true },
};

export function AutomationRow({
Expand Down Expand Up @@ -196,7 +196,7 @@ export function AutomationRow({
<span
className={cn(
"flex items-center gap-1.5",
meta.failed && "text-red-600 dark:text-red-400",
meta.failed && "text-destructive",
)}
>
<span
Expand Down
Loading
Loading