Skip to content

Commit cb46d4c

Browse files
Gheat1hamidi-dev
authored andcommitted
perf(tui): memoize subagent nodes per session
detail_subagents re-ran store.workflow_nodes (a recursive CTE or a backend parse) on every paint of the Subagents tab -- every scroll step and every 200ms toast repaint. Cache the rows per session in _nodes_by_session, the _tool_by_session/_turns_by_session pattern, cleared in the same reload/source-switch places; the export dataset reads through the same memo.
1 parent 40ec8d3 commit cb46d4c

3 files changed

Lines changed: 59 additions & 2 deletions

File tree

src/opentab/tui/app.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,9 @@ def __init__(self, store: Store, args: argparse.Namespace, source_key: str = "")
177177
# Per-turn timeline (OpenCode + Claude), same lazy/cached-per-session deal as
178178
# the tool rows above -- a cheap per-session scan, never loaded at startup.
179179
self._turns_by_session: dict[str, list[dict]] = {}
180+
# Subagent tree per session (workflow_nodes: a recursive CTE / backend parse),
181+
# same lazy/cached-per-session deal -- the Subagents tab repaints every frame.
182+
self._nodes_by_session: dict[str, list[dict]] = {}
180183
# Active range: custom bounds from CLI take precedence, else a day window
181184
# (None = all). Default is all time so the Months panel is actually useful.
182185
self.custom_since = args.since
@@ -1061,6 +1064,18 @@ def _scale_demo_turns(self, workflow_id: str, rows: list[dict]) -> list[dict]:
10611064
r["cost"] = round(r.get("cost", 0) * k, 4)
10621065
return rows
10631066

1067+
def session_node_rows(self, workflow_id: str) -> list[dict]:
1068+
# Subagent tree for one session, fetched once and cached. The store call is the
1069+
# heavy bit (a recursive CTE / backend parse) and detail_subagents runs on every
1070+
# paint, so memoize like session_tool_rows; the store already demo-scales nodes,
1071+
# and _priced_nodes copies rows before repricing, so the memo stays pristine.
1072+
cached = self._nodes_by_session.get(workflow_id)
1073+
if cached is not None:
1074+
return cached
1075+
rows = [dict(r) for r in self.store.workflow_nodes(workflow_id)]
1076+
self._nodes_by_session[workflow_id] = rows
1077+
return rows
1078+
10641079
def _snapshot_real_costs(self) -> None:
10651080
# Freshly loaded rows carry only real cost; seed the real/api snapshots so
10661081
# _apply_price_mode is safe even before the (deferred) model scan runs.
@@ -1213,6 +1228,7 @@ def reload(self) -> None:
12131228
self._resolve_project_roots()
12141229
self._tool_by_session.clear()
12151230
self._turns_by_session.clear()
1231+
self._nodes_by_session.clear()
12161232
self._load_model_cache()
12171233
self.zoom_project = None
12181234
self.workflow_index = min(self.workflow_index, max(0, len(self.workflows) - 1))
@@ -1378,6 +1394,7 @@ def _reload_for_source(self, restore: dict | None = None) -> None:
13781394
self._models_loaded = False
13791395
self._tool_by_session.clear()
13801396
self._turns_by_session.clear()
1397+
self._nodes_by_session.clear()
13811398
self._load_model_cache()
13821399
self._invalidate_workflow_cache()
13831400
if restore:
@@ -1779,7 +1796,7 @@ def _models_dataset(rows: list) -> tuple[str, list[str], list[list]]:
17791796

17801797
def _subagents_dataset(self, session: Workflow) -> tuple[str, list[str], list[list]]:
17811798
nodes = self._priced_nodes(
1782-
[r for r in self.store.workflow_nodes(session.id) if r["depth"] > 0]
1799+
[r for r in self.session_node_rows(session.id) if r["depth"] > 0]
17831800
)
17841801
header = ["depth", "agent", "model", "cost", "tokens", "title"]
17851802
rows = [

src/opentab/tui/renderer.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1768,7 +1768,7 @@ def detail_models(self, workflow: Workflow, width: int) -> list[str]:
17681768

17691769
def detail_subagents(self, workflow: Workflow, width: int) -> list[str]:
17701770
rows = self._priced_nodes(
1771-
[row for row in self.store.workflow_nodes(workflow.id) if row["depth"] > 0]
1771+
[row for row in self.session_node_rows(workflow.id) if row["depth"] > 0]
17721772
)
17731773
if not rows:
17741774
return ["# Subagents", "No subagents used in this workflow."]

test_opentab.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7461,6 +7461,46 @@ def test_records_cost_probe_runs_lazily_not_at_construction():
74617461
assert sub.records_cost is False
74627462

74637463

7464+
def test_subagent_nodes_memoized_per_session():
7465+
def node(workflow_id, depth, agent, title):
7466+
return {
7467+
"id": f"{workflow_id}:{depth}",
7468+
"depth": depth,
7469+
"agent": agent,
7470+
"title": title,
7471+
"created_at": "",
7472+
"cost": 1.0,
7473+
"model_name": "anthropic/x",
7474+
"tokens_input": 1,
7475+
"tokens_output": 1,
7476+
"tokens_reasoning": 0,
7477+
"tokens_cache_read": 0,
7478+
"tokens_cache_write": 0,
7479+
"tokens_total": 2,
7480+
}
7481+
7482+
class NodeStore(FakeStore):
7483+
node_calls = 0
7484+
7485+
def workflow_nodes(self, workflow_id):
7486+
self.node_calls += 1
7487+
return [node(workflow_id, 0, "-", "root"), node(workflow_id, 1, "task", "sub")]
7488+
7489+
args = type("Args", (), {"since": None, "until": None, "days": None})()
7490+
app = ot.App(NodeStore([workflow("s1", "2026-06-01 12:00:00")]), args)
7491+
rows1 = app.session_node_rows("s1")
7492+
rows2 = app.session_node_rows("s1")
7493+
assert app.store.node_calls == 1 # every repaint after the first is memo-served
7494+
assert rows1 is rows2 and [r["depth"] for r in rows1] == [0, 1]
7495+
# The Subagents export dataset reads through the same memo (no new store call).
7496+
kind, header, rows = app._subagents_dataset(app.loaded[0])
7497+
assert kind == "subagents" and app.store.node_calls == 1
7498+
assert [r[0] for r in rows] == [1] # depth-0 root filtered out, subagent kept
7499+
# Reload drops the memo -- the underlying data may have changed.
7500+
app.reload()
7501+
app.session_node_rows("s1")
7502+
assert app.store.node_calls == 2
7503+
74647504

74657505
def test_wsl_mount_root_and_windows_path_mapping():
74667506
with tempfile.TemporaryDirectory() as tmp:

0 commit comments

Comments
 (0)