Skip to content
Merged
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
22 changes: 21 additions & 1 deletion src/toad/navigation_target.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@
from agent_comms.channel_targets import ChannelTarget as CoreChannelTarget
from agent_comms.channel_targets import ThreadTarget as CoreThreadTarget
from agent_comms.nominal.declared_family import DeclaredFamily
from agent_comms.thread_identity import ThreadIncarnation

from toad.comms_root import Observing
from toad.work_preparation import PreparationRuntime
from toad.core.events import CoreEvent
from toad.constants import ALL_COMMS_TARGET
from toad.navigation_context import NavigationContext, NavigationOwner
Expand Down Expand Up @@ -98,9 +101,26 @@ async def open(self, context: NavigationContext) -> str:
class ThreadTarget(NavigationTarget):
async def open(self, context: NavigationContext) -> str:
return await context.app.thread_navigation.open(
owner_mode=context.owner_mode, project_path=context.project_path, target=self.name,
owner_mode=context.owner_mode, project_path=context.project_path, target=self,
)

async def incarnation_in(self, preparation: PreparationRuntime, observed: Observing) -> ThreadIncarnation:
"""A name names a thread by its current birth, which Core answers off the loop."""
name = self.name
return await preparation.run_thread(
lambda: observed.registry_for(name).require(name).incarnation)


@dataclass(frozen=True)
class KnownThreadTarget(ThreadTarget):
"""A thread already drawn: its row holds the birth, so opening it asks Core nothing before
its tab shows; the tab's own read confirms the birth behind the loading view."""

incarnation: ThreadIncarnation

async def incarnation_in(self, preparation: PreparationRuntime, observed: Observing) -> ThreadIncarnation:
return self.incarnation


class HistoryRoute:
"""A route declaration registers its conversation kind exactly once."""
Expand Down
4 changes: 2 additions & 2 deletions src/toad/thread_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,8 @@ async def completed(self, app, selected, result: CliResult):
await app.session_navigation.retire_missing()
await app.session_navigation.reconnect(selected, result.reconnect_targets())
for target in result.open_targets():
await app.thread_navigation.open(owner_mode=self.context.mode,
project_path=self.context.project, target=target)
await app.thread_navigation.open_named(owner_mode=self.context.mode,
project_path=self.context.project, name=target)
result.show(app, self.definition.label, self.context.title)

@classmethod
Expand Down
17 changes: 9 additions & 8 deletions src/toad/thread_navigation.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

from agent_comms.errors import RelationViolationError
from agent_comms.repo import Checkout
from toad.navigation_target import ThreadTarget
from toad.tab_identity.kinds import MailboxTab
from toad.navigation_preparation import (
OpenThread,
Expand Down Expand Up @@ -52,7 +53,11 @@ def __init__(self, app: ToadApp) -> None:
self.app = app
self.pending: dict[tuple[str, str, str], ThreadOpening] = {}

async def open(self, *, owner_mode: str, project_path: Checkout, target: str) -> str:
async def open_named(self, *, owner_mode: str, project_path: Checkout, name: str) -> str:
"""A thread known only by name (a command's result, a label): Core answers its birth."""
return await self.open(owner_mode=owner_mode, project_path=project_path, target=ThreadTarget(name))

async def open(self, *, owner_mode: str, project_path: Checkout, target: ThreadTarget) -> str:
app = self.app
try:
source = app.workspace_sessions.origin(owner_mode).require()
Expand All @@ -64,25 +69,21 @@ async def open(self, *, owner_mode: str, project_path: Checkout, target: str) ->
requested_root = str(observed.root)
if not origin.current(self, owner_mode):
return app.selected_mode
identity = await app.preparation.run_thread(
lambda: MailboxTab(
observed.registry_for(target).require(target).incarnation, requested_root
)
)
identity = MailboxTab(await target.incarnation_in(app.preparation, observed), requested_root)
mounted_root = str(Path(requested_root).expanduser())
open_threads = []
for details in app.session_tracker.ordered_sessions:
view = app.session_navigation.source(details.mode_name)
if view is None:
continue
root, name = view.coordination_root, view.thread_identity.thread_name()
if (root, name) == (mounted_root, target):
if (root, name) == (mounted_root, target.name):
await app.select_session(details.mode_name)
return details.mode_name
if root is not None:
open_threads.append(OpenThread(details.mode_name, root, name))
request = ThreadNavigationRequest(
observed, target, project_path, tuple(open_threads)
observed, target.name, project_path, tuple(open_threads)
)
key = owner_mode, request.root, request.target
if pending := self.pending.get(key):
Expand Down
5 changes: 3 additions & 2 deletions src/toad/widgets/comms_sidebar.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
from toad.thread_identity import SessionIdentity
from toad.widgets.thread_identity import ShowsThreadIdentity
from toad.core_event_carrier import CoreEventReceiver
from toad.navigation_target import channel_target, ThreadTarget
from toad.navigation_target import KnownThreadTarget, NavigationTarget, ThreadTarget, channel_target
from toad.session_tracker import SidebarSelection
from toad.widgets.selection import HoverSelection, ViewLabel
from toad.application_actions import NewThreadAction
Expand All @@ -48,7 +48,6 @@
from toad.tone_paint import AlternatingTone
from toad.textual_colours import ChannelAlternateRowToneColour, ChannelRowToneColour
from toad.widgets.side_bar import SideBar, SideBarCollapsible, SidebarPanelWidget, SidebarVisibilityObserver
from toad.navigation_target import NavigationTarget
from toad.app_node import ToadNode
from toad.native_style import (
AutoHeight,
Expand Down Expand Up @@ -300,6 +299,8 @@ def show(self, row: ThreadRowModel, *, pinned: bool = False) -> None:
return
previous = self.thread_row
self.thread_row, self.pinned, self.action_status = row, pinned, action_status
# The drawn row knows the thread's birth: opening it asks Core nothing first.
self.target = KnownThreadTarget(row.name, row.incarnation)
summary = action_status if action_status is not None else row.summary
self.tooltip = Content("\n".join(value for value in (
row.name, summary, row.unread.detail, "Pinned in this channel" if pinned else None, *row.model.texts(),
Expand Down
4 changes: 2 additions & 2 deletions src/toad/widgets/session_details.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,6 @@ async def _on_collapsible_title_toggle(self, event) -> None:
event.prevent_default()
event.stop()
context = self.query_ancestor(SessionScope).navigation_owner.navigation_context
await self.app.thread_navigation.open(
await self.app.thread_navigation.open_named(
owner_mode=context.owner_mode, project_path=context.project_path,
target=self.label)
name=self.label)
25 changes: 19 additions & 6 deletions tests/app/test_channel_mentions.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
"""Completing a thread mention in a channel keeps the draft and delivers to the channel."""


from toad.line_blocks import OpenTarget
from agent_comms.channel_targets import ChannelTarget
from agent_comms.display_references import ThreadDisplayReference
from agent_comms.repo import Checkout

from toad.link_click import OpenLineLink
from toad.line_blocks import OpenReference

async def test_mention_completion_delivery_and_link(workspace):
workspace.thread("alpha", "team")
alpha = workspace.thread("alpha", "team")
workspace.thread("beta", "team")
comms = workspace.comms
async with workspace.app(size=(100, 35)) as ui:
Expand All @@ -15,14 +20,22 @@ async def test_mention_completion_delivery_and_link(workspace):
assert ui.draft() == "Can @alpha "
await ui.type("review this?")
await ui.press("enter")
await ui.until(lambda: len(comms.views.channel_history("#team")) == 1, why="the message on the wire")
sent = comms.views.channel_history("#team")[0]
team = ChannelTarget("#team").display_history(comms.views, Checkout(workspace.project))

def on_the_wire():
return team.page(comms.views.presentation).messages

await ui.until(lambda: len(on_the_wire()) == 1, why="the message on the wire")
sent = on_the_wire()[0]
assert sent.target == "#team" and sent.body == "Can @alpha review this?"
assert sent.mentions[0].thread == "alpha"
assert len(comms.bus.inbox("alpha")) == len(comms.bus.inbox("beta")) == 1
assert len(comms.bus.inbox("alpha").page.messages) == len(comms.bus.inbox("beta").page.messages) == 1
await ui.until_shows("Can @alpha review this?")
# The name becomes a link when the message's styled lines replace its plain ones.
await ui.until(lambda: ui.link_at("@alpha review") == OpenTarget("alpha").click,
# A mention names alpha's exact birth on this wire, not whichever thread holds the name later.
mention = OpenLineLink(OpenReference(ThreadDisplayReference(
root=str(workspace.roots.wire), incarnation=alpha.incarnation)))
await ui.until(lambda: ui.link_at("@alpha review") == mention,
why="the mention drawn as a link")

await ui.write_draft("@")
Expand Down
3 changes: 2 additions & 1 deletion tests/app/test_markdown_file_links.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""A file named in an agent answer is a link to the project file; clicking it opens the file's preview and returning keeps the draft."""
from toad.link_click import OpenLineLink
from toad.session_scope import AgentAttached

from support.agents import peer
Expand All @@ -25,7 +26,7 @@ async def test_file_link_in_answer_opens_preview_and_return_keeps_draft(workspac
await ui.until_shows("PEER_ANSWER")
await ui.write_draft("KEEP_MARKDOWN_DRAFT")
links = file_links(ui, "file.md")
opens_file = DirectProjectFileLink(target, original_root=workspace.project).click
opens_file = OpenLineLink(DirectProjectFileLink(target, original_root=workspace.project))
assert sum(link == opens_file for link in links) == 2, links
assert any("https://example.com" in str(link) for link in file_links(ui, "website")), ui.visible_text()
drawn = [i for i, line in enumerate(line for line in ui.visible_lines() if "file.md" in line)]
Expand Down
4 changes: 2 additions & 2 deletions tests/app/test_native_message_parts.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ async def test_saved_message_in_parts_shows_as_one_message(workspace):
session = write_journal(workspace.project / "native.jsonl", entry)
workspace.comms.registry.declare(NativeThread("worker", frozenset(), Checkout(workspace.project), session_file=RegistrySessionLocation.from_path(session, workspace.comms.root)))
async with workspace.app(size=(110, 38)) as ui:
await ui.app.thread_navigation.open(
owner_mode=ui.selected_tab, project_path=ui.app.project_dir, target="worker")
await ui.app.thread_navigation.open_named(
owner_mode=ui.selected_tab, project_path=ui.app.project_dir, name="worker")
await ui.step()
await ui.until(lambda: ui.shows("value = 123") and not ui.shows("## Plan"), why="the styled message")
lines = [line.strip(" │▌").strip() for line in ui.visible_lines()]
Expand Down
8 changes: 4 additions & 4 deletions tests/app/test_transcript_recipient.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,12 @@ async def test_unbound_tab_refuses_another_threads_snapshot(workspace):
identity, thread=replace(identity.thread, created_at=identity.thread.created_at - 0.5)), owner)

async with workspace.app(size=(110, 38)) as ui:
alpha_tab = await ui.app.thread_navigation.open(
owner_mode=ui.selected_tab, project_path=ui.app.project_dir, target="alpha")
alpha_tab = await ui.app.thread_navigation.open_named(
owner_mode=ui.selected_tab, project_path=ui.app.project_dir, name="alpha")
await ui.until(lambda: ui.shows("Alpha-only history"), why="alpha's saved history")

await ui.app.thread_navigation.open(
owner_mode=alpha_tab, project_path=ui.app.project_dir, target="beta")
await ui.app.thread_navigation.open_named(
owner_mode=alpha_tab, project_path=ui.app.project_dir, name="beta")
await ui.step()
beta = ui.detached_agent(None)
ui.attach_agent(beta)
Expand Down
6 changes: 3 additions & 3 deletions tests/native/test_native_journey.py
Original file line number Diff line number Diff line change
Expand Up @@ -232,8 +232,8 @@ def reply(request, number):
model=parent.model, thinking_level=parent.thinking_level,
).apply, comms)
assert result.created == "fork"
child_tab = await app.thread_navigation.open(
owner_mode=parent_tab, project_path=app.project_dir, target="fork")
child_tab = await app.thread_navigation.open_named(
owner_mode=parent_tab, project_path=app.project_dir, name="fork")
assert child_tab != parent_tab and ui.selected_tab == child_tab
await ui.until_agent_ready()
view = ui.conversation
Expand Down Expand Up @@ -340,7 +340,7 @@ async def declare_second(comms, project, _provider):
app, _pilot = ui.textual
owner_tab = ui.selected_tab
await ui.until_agent_ready()
opened = await app.thread_navigation.open(owner_mode=owner_tab, project_path=app.project_dir, target="gamma")
opened = await app.thread_navigation.open_named(owner_mode=owner_tab, project_path=app.project_dir, name="gamma")
assert opened != owner_tab and ui.selected_tab == opened
assert [tab.identity for tab in ui.tabs()] == [owner_tab, opened]
view = ui.conversation
Expand Down
2 changes: 1 addition & 1 deletion tests/native/test_thread_open_during_slow_load.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ def journey_group(roster):
await ui.until(lambda: "fresh-peer" in group.members, why="the channel's members")
tabs_before = tuple(app.tab_order.names)

open_peer = partial(app.thread_navigation.open, owner_mode=owner, project_path=project, target="fresh-peer")
open_peer = partial(app.thread_navigation.open_named, owner_mode=owner, project_path=project, name="fresh-peer")
assert await pilot.click(group.members["fresh-peer"])
opening = asyncio.create_task(open_peer())
duplicate = asyncio.create_task(open_peer())
Expand Down
8 changes: 5 additions & 3 deletions tests/support/ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
from pathlib import Path
from typing import TYPE_CHECKING

from textual.actions import StyleAction

from rich.cells import cell_len
from support.agents import AppAgents, command
from toad import paths
Expand Down Expand Up @@ -102,8 +104,8 @@ async def click(self, text: str, *, occurrence: int = 0) -> None:
"""Click the first cell of `text` where it is drawn on screen."""

@abstractmethod
def link_at(self, text: str, *, occurrence: int = 0) -> tuple[str, tuple] | None:
"""The action a click on `text` would trigger, as (name, arguments), if it is a link."""
def link_at(self, text: str, *, occurrence: int = 0) -> StyleAction | None:
"""The action a click on `text` would run, if it is a link."""

@abstractmethod
def hyperlink_at(self, text: str, *, occurrence: int = 0) -> str | None:
Expand Down Expand Up @@ -336,7 +338,7 @@ async def click(self, text: str, *, occurrence: int = 0) -> None:
), f"the click on {text!r} left the screen"
await self.step()

def link_at(self, text: str, *, occurrence: int = 0) -> tuple[str, tuple] | None:
def link_at(self, text: str, *, occurrence: int = 0) -> StyleAction | None:
return self.app.screen.get_style_at(*self._cell_of(text, occurrence)).meta.get(
"@click"
)
Expand Down
3 changes: 2 additions & 1 deletion tests/textual/test_block_menu_and_copy.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import pytest
from textual.widgets import Static

from toad.link_click import OpenLineLink
from toad.block_content import BlockContent
from toad.block_navigation import ConversationBlock
from toad.widgets.conversation import FocusPromptAction
Expand Down Expand Up @@ -113,7 +114,7 @@ async def send_and_preview(tab, target):
)
await ui.until_shows("clipboard-")
expected = DirectProjectFileLink(path, original_root=workspace.project)
await ui.until(lambda: ui.link_at("clipboard-") == expected.click,
await ui.until(lambda: ui.link_at("clipboard-") == OpenLineLink(expected),
why="the complete quoted attachment rendered as a file link")
await ui.click("clipboard-")
await ui.until(lambda: ui.selected_tab != tab, why="the image preview tab")
Expand Down
9 changes: 5 additions & 4 deletions tests/textual/test_existing_thread_link.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from unittest.mock import patch

from toad.line_blocks import OpenTarget
from toad.link_click import OpenLineLink
from toad.message_rows import MessageRow
from toad.widgets.comms_chat import CommsChatView
from toad.widgets.goal_bar import GoalBar
Expand Down Expand Up @@ -31,7 +32,7 @@ async def test_name_of_an_open_thread_selects_its_tab_and_creates_nothing(worksp
await ui.select_tab(owner)
await ui.open_channel("#team", actor="owner", owner=owner)
await ui.until_shows(header.label)
await ui.until(lambda: app.screen.get_style_at(*ui.link_cell(header, sender)).meta.get("@click") == sender.click,
await ui.until(lambda: app.screen.get_style_at(*ui.link_cell(header, sender)).meta.get("@click") == OpenLineLink(sender),
why="the sender drawn as a link")
# The channel tab's unread count clears as its message is read; the tabs themselves must not change.
def order():
Expand All @@ -49,8 +50,8 @@ def order():
assert not app.thread_navigation.pending
# Repeated and self links reuse the very same mounted tab too.
for _ in range(2):
assert await app.thread_navigation.open(
owner_mode=existing, project_path=ui.app.project_dir, target="peer") == existing
assert await app.thread_navigation.open_named(
owner_mode=existing, project_path=ui.app.project_dir, name="peer") == existing
assert reads.call_count == 0
assert order() == opened

Expand Down Expand Up @@ -82,7 +83,7 @@ async def test_name_clicked_in_a_narrow_direct_chat_reuses_the_open_thread_tab(w
clip = app.screen._compositor.find_widget(underline).clip
assert underline.region.intersection(clip).height == 1
await ui.until_shows(header.label)
await ui.until(lambda: app.screen.get_style_at(*ui.link_cell(header, sender)).meta.get("@click") == sender.click,
await ui.until(lambda: app.screen.get_style_at(*ui.link_cell(header, sender)).meta.get("@click") == OpenLineLink(sender),
why="the sender drawn as a link")
# The chat tab's title drops its unread count once the message is marked read.
order = [tab.identity for tab in ui.tabs()]
Expand Down
5 changes: 3 additions & 2 deletions tests/textual/test_hovered_name_link.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Textual-facing: the pointer over one name in a chat highlights that name only, not every equal name on screen."""


from toad.link_click import OpenLineLink
from toad.line_blocks import OpenTarget
from toad.message_rows import MessageRow

Expand All @@ -17,7 +18,7 @@ async def test_the_pointer_over_one_sender_highlights_that_one_and_leaves_equal_
app, pilot = ui.textual
await ui.open_channel("#team", actor="owner", owner=ui.selected_tab)
await ui.until(lambda: ui.visible_text().count(header.label) == 3, why="three messages from one sender")
await ui.until(lambda: all(app.screen.get_style_at(*ui.link_cell(header, sender, occurrence=n)).meta.get("@click") == sender.click for n in range(3)),
await ui.until(lambda: all(app.screen.get_style_at(*ui.link_cell(header, sender, occurrence=n)).meta.get("@click") == OpenLineLink(sender) for n in range(3)),
why="three messages whose sender is the same link")
cells = [ui.link_cell(header, sender, occurrence=n) for n in range(3)]
body, _ = app.screen.get_widget_at(*cells[1])
Expand All @@ -30,6 +31,6 @@ async def test_the_pointer_over_one_sender_highlights_that_one_and_leaves_equal_
assert [app.screen.get_style_at(*cells[n]).bgcolor for n in (0, 2)] == [resting[0], resting[2]]
# The destination on the same row is another link: it stays as drawn too.
target = ui.link_cell(header, OpenTarget("#team"), occurrence=1)
assert app.screen.get_style_at(*target).meta["@click"] == OpenTarget("#team").click
assert app.screen.get_style_at(*target).meta["@click"] == OpenLineLink(OpenTarget("#team"))
assert app.screen.get_style_at(*target).bgcolor == resting[1]
assert ui.failure() is None
Loading
Loading