From a556c73a898b1d60c437e58fe1aea234364153a8 Mon Sep 17 00:00:00 2001 From: Vincelwt Date: Thu, 27 Aug 2026 22:36:57 +0200 Subject: [PATCH 1/3] fix: mobile opens what was said, and a closed task takes a reply A notification about something said to you opened the run's activity log instead of the conversation, so Home led to lifecycle events and tool calls rather than the task and its discussion. It now opens where it was said, the way the desktop already did. Settings, agents, automations, members and the workspace switcher went with the More tab; the tab is back and the Home header loses its duplicate. A scroll view that adjusts its content automatically already clears the status bar on iOS, so a screen drawing its own title row was paying the top inset twice. Two bottom-anchored screens keep an opaque bar for the same reason. Naming an agent on a closed task answered with an error and a chore. The relay reopens the task itself: only a person can, and a person just did. --- crates/patchwork-relay/src/orchestrator.rs | 107 ++++++++++++++++++ mobile/src/app/(app)/(tabs)/_layout.tsx | 15 +++ .../src/app/(app)/(tabs)/channels/index.tsx | 4 +- mobile/src/app/(app)/(tabs)/home/index.tsx | 20 +--- mobile/src/app/(app)/(tabs)/more/_layout.tsx | 5 + .../(app)/{more.tsx => (tabs)/more/index.tsx} | 7 +- mobile/src/app/(app)/runs/[runId].tsx | 5 +- mobile/src/app/(app)/threads/[messageId].tsx | 2 +- mobile/src/components/TaskDetail.tsx | 2 + mobile/src/lib/layout.ts | 5 + mobile/src/lib/paired.test.ts | 11 ++ mobile/src/lib/paired.ts | 9 ++ 12 files changed, 170 insertions(+), 22 deletions(-) create mode 100644 mobile/src/app/(app)/(tabs)/more/_layout.tsx rename mobile/src/app/(app)/{more.tsx => (tabs)/more/index.tsx} (94%) diff --git a/crates/patchwork-relay/src/orchestrator.rs b/crates/patchwork-relay/src/orchestrator.rs index 632b2d4..c1d2524 100644 --- a/crates/patchwork-relay/src/orchestrator.rs +++ b/crates/patchwork-relay/src/orchestrator.rs @@ -698,6 +698,36 @@ async fn trigger_agents( } } + // Naming an agent on a closed task is asking for more work on it, so the + // task reopens itself rather than answering a person with an error and a + // chore. Only a person can do this; an agent still cannot reopen its own. + if author.kind == MemberKind::Human && channel.kind == ChannelKind::Task { + let addressed = reply_agent.is_some() + || members.iter().any(|member| { + member.kind == MemberKind::Agent && message.mentions.contains(&member.id) + }); + let closed = channel + .task_id + .as_deref() + .and_then(|id| state.store.task(id).ok().flatten()) + .filter(|task| task.status.is_terminal()); + if let (true, Some(task)) = (addressed, closed) { + // Boxed: reopening posts a system note, which comes back through + // here for a message no agent can be triggered by. + Box::pin(update_task( + state, + &message.author_id, + false, + &task.id, + patchwork_core::wire::UpdateTask { + status: Some(TaskStatus::Planned), + ..Default::default() + }, + )) + .await?; + } + } + // A task is already addressed: its owner is the recipient. let task_owner = if author.kind == MemberKind::Human && message.mentions.is_empty() @@ -4693,6 +4723,83 @@ mod tests { let _ = std::fs::remove_file(path); } + #[tokio::test] + async fn naming_an_agent_on_a_closed_task_reopens_it_instead_of_refusing() { + let path = std::env::temp_dir().join(format!("patchwork-reopen-{}.sqlite", new_id())); + let store = Store::open(&path).unwrap(); + store.create_workspace("workspace", "Test").unwrap(); + let human = member("human", "vince", MemberKind::Human); + let mut agent = member("agent", "claude", MemberKind::Agent); + agent.agent.as_mut().unwrap().default_participation = Participation::Mention; + store.insert_member(&human).unwrap(); + store.insert_member(&agent).unwrap(); + let members = [human, agent]; + let state = std::sync::Arc::new(crate::state::AppState::new( + store.clone(), + path.with_extension("files"), + "http://workspace".into(), + "relay".into(), + )); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + state + .hosts + .write() + .await + .insert("relay".into(), crate::state::HostConn { tx }); + let task = create_task_with_result( + &state, + "human", + patchwork_core::wire::CreateTask { + title: "Ship the thing".into(), + outcome: "It is shipped".into(), + owner_id: Some("agent".into()), + status: Some(TaskStatus::Planned), + start: false, + ..Default::default() + }, + ) + .await + .unwrap() + .task; + let task = update_task( + &state, + "human", + false, + &task.id, + patchwork_core::wire::UpdateTask { + status: Some(TaskStatus::Done), + ..Default::default() + }, + ) + .await + .unwrap(); + assert_eq!(task.status, TaskStatus::Done); + let channel = store.channel(&task.discussion_channel_id).unwrap().unwrap(); + let mut nudge = message_at("nudge", "human", 9, None); + nudge.channel_id = channel.id.clone(); + nudge.task_id = Some(task.id.clone()); + nudge.body = "@claude this is still broken".into(); + nudge.mentions = vec!["agent".into()]; + store.insert_message(&nudge).unwrap(); + + trigger_agents(&state, &nudge, &channel, &members) + .await + .unwrap(); + + assert!( + !store.task(&task.id).unwrap().unwrap().status.is_terminal(), + "a person writing to a closed task reopens it" + ); + let RelayToHost::StartRun { spec } = rx.recv().await.unwrap() else { + panic!("expected the named agent to start on the reopened task"); + }; + assert_eq!(spec.agent_id, "agent"); + + drop(state); + drop(store); + let _ = std::fs::remove_file(path); + } + #[tokio::test] async fn a_mention_does_not_summon_an_outside_agent_into_a_dm() { let path = std::env::temp_dir().join(format!("patchwork-dm-{}.sqlite", new_id())); diff --git a/mobile/src/app/(app)/(tabs)/_layout.tsx b/mobile/src/app/(app)/(tabs)/_layout.tsx index 3fc0ed7..8a08b22 100644 --- a/mobile/src/app/(app)/(tabs)/_layout.tsx +++ b/mobile/src/app/(app)/(tabs)/_layout.tsx @@ -1,6 +1,10 @@ import { NativeTabs } from "expo-router/unstable-native-tabs"; +import type { SFSymbol } from "sf-symbols-typescript"; + import { unreadInboxCount } from "@client/inbox"; +import { workspaceSymbol } from "@/lib/paired"; +import { usePairedSession } from "@/lib/session"; import { useWorkspace } from "@/lib/store"; import { useTheme } from "@/lib/theme"; @@ -8,6 +12,13 @@ export default function TabLayout() { const theme = useTheme(); const bootstrap = useWorkspace().bootstrap; const unread = unreadInboxCount(bootstrap?.inbox ?? []); + const { session } = usePairedSession(); + // Which workspace is on screen rides on the More tab, the way a settings tab + // carries the current account, instead of taking a header row on every tab. + const workspace = workspaceSymbol(session && bootstrap ? { ...session, name: bootstrap.workspace.name } : session) as { + default: SFSymbol; + selected: SFSymbol; + }; return ( Chats + + + More + Search diff --git a/mobile/src/app/(app)/(tabs)/channels/index.tsx b/mobile/src/app/(app)/(tabs)/channels/index.tsx index e2d1f96..381c91d 100644 --- a/mobile/src/app/(app)/(tabs)/channels/index.tsx +++ b/mobile/src/app/(app)/(tabs)/channels/index.tsx @@ -7,7 +7,7 @@ import type { Channel, Id } from "@client/types"; import { Conversation } from "@/components/Message"; import { Avatar, Button, ChoiceField, Empty, ErrorNotice, Glass, Icon, Measured, Sheet, TextField } from "@/components/ui"; import { relative } from "@/lib/format"; -import { useLayout } from "@/lib/layout"; +import { autoTopInset, useLayout } from "@/lib/layout"; import { useWorkspace, useWorkspaceStore } from "@/lib/store"; import { useTheme } from "@/lib/theme"; @@ -114,7 +114,7 @@ export default function ChannelsScreen() { ) : ( {inlineTitle ? ( - + Chats {actions} diff --git a/mobile/src/app/(app)/(tabs)/home/index.tsx b/mobile/src/app/(app)/(tabs)/home/index.tsx index 41d6465..66498d7 100644 --- a/mobile/src/app/(app)/(tabs)/home/index.tsx +++ b/mobile/src/app/(app)/(tabs)/home/index.tsx @@ -6,10 +6,9 @@ import { groupInbox, unreadInboxCount } from "@client/inbox"; import type { InboxGroup } from "@client/inbox"; import type { Ask, InboxItem, InboxKind } from "@client/types"; import { AskCard } from "@/components/AskCard"; -import { WorkspaceMark } from "@/components/WorkspaceSwitcher"; import { Avatar, Button, Empty, Icon, Measured, Screen } from "@/components/ui"; import { relative } from "@/lib/format"; -import { usePairedSession } from "@/lib/session"; +import { autoTopInset } from "@/lib/layout"; import { useWorkspace, useWorkspaceStore } from "@/lib/store"; import { useTheme } from "@/lib/theme"; @@ -26,7 +25,6 @@ export default function HomeScreen() { const workspace = useWorkspace(); const store = useWorkspaceStore(); const insets = useSafeAreaInsets(); - const { session } = usePairedSession(); const data = workspace.bootstrap; const groups = groupInbox(data?.inbox ?? []); const unread = unreadInboxCount(data?.inbox ?? []); @@ -50,10 +48,13 @@ export default function HomeScreen() { false, ).catch(() => undefined); } + // What arrived was said somewhere, so it opens where it was said. A run's + // activity log is a drill-down from there, never the destination for + // something addressed to a person. if (item.task_id) return router.push({ pathname: "/tasks/[taskId]", params: { taskId: item.task_id } }); - if (item.run_id) return router.push({ pathname: "/(app)/runs/[runId]", params: { runId: item.run_id } }); if (item.channel_id) return router.push({ pathname: "/channels/[channelId]", params: { channelId: item.channel_id } }); if (item.automation_id) return router.push({ pathname: "/(app)/automations/[automationId]", params: { automationId: item.automation_id } }); + if (item.run_id) return router.push({ pathname: "/(app)/runs/[runId]", params: { runId: item.run_id } }); }; return ( @@ -71,20 +72,11 @@ export default function HomeScreen() { )} ListHeaderComponent={ - + Home {unread ? (