Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
71 commits
Select commit Hold shift + click to select a range
5b8cfdb
feat: add Hermes gateway provider
bmdavis419 Jul 24, 2026
19d1329
fix: harden Hermes instance lifecycle
bmdavis419 Jul 24, 2026
0b837b8
fix: isolate web auth cookies by port
bmdavis419 Jul 24, 2026
5faee61
fix: harden Hermes gateway streaming lifecycle
bmdavis419 Jul 25, 2026
1fa4219
fix: serialize Hermes activity lifecycle
bmdavis419 Jul 25, 2026
35ddbc4
fix: support reverse-proxied dev origins
bmdavis419 Jul 25, 2026
965a76b
fix: recover legacy Hermes gateway state
bmdavis419 Jul 25, 2026
5b51977
fix: unify assistant text merge and detach status text from unknown
bmdavis419 Jul 25, 2026
e8be967
feat: surface the Hermes model on instance status
bmdavis419 Jul 25, 2026
758f999
fix: correct Hermes plugin steer capture, model reporting, and task s…
bmdavis419 Jul 25, 2026
3261623
fix: make Hermes pairing recoverable and self-updating
bmdavis419 Jul 25, 2026
e33c0d8
fix: finish Hermes reconnect and split gateway state by durability
bmdavis419 Jul 25, 2026
e78badd
fix: proxy websocket upgrades to the server in reverse-proxied dev
bmdavis419 Jul 25, 2026
422cf08
fix: make copy buttons work over plain http and surface the restart c…
bmdavis419 Jul 25, 2026
6d341a1
fix: re-ensure Hermes sessions when the connection is replaced
bmdavis419 Jul 25, 2026
9fb0ba2
fix: stop the liveness probe from killing healthy Hermes connections
bmdavis419 Jul 25, 2026
18f7243
fix: do not treat a racing ping as the Hermes handshake reply
bmdavis419 Jul 25, 2026
f684971
fix: keep Hermes sessions alive when T3 shuts down
bmdavis419 Jul 25, 2026
c696e04
fix: resume Hermes sessions off the status stream instead of blocking it
bmdavis419 Jul 25, 2026
88cfe26
docs: plan Hermes agent UX — directoryless threads and a generic Agen…
bmdavis419 Jul 26, 2026
0fde14a
feat: directoryless Hermes threads and a generic Agent page
bmdavis419 Jul 26, 2026
7a92ec7
fix: stop the Agent page overflowing its inset
bmdavis419 Jul 26, 2026
26e3c30
fix: clamp skill descriptions to one line on the Agent page
bmdavis419 Jul 26, 2026
6a76a01
fix: Hermes turns ended at the first tool call
bmdavis419 Jul 26, 2026
3f71c60
fix: complete Hermes turns on notify, never on finalize
bmdavis419 Jul 26, 2026
a4000b8
fix: fold the Hermes status line into the turn's activity
bmdavis419 Jul 26, 2026
5bfb90d
fix: fold every settled-turn activity behind "Worked for …"
bmdavis419 Jul 26, 2026
784fcb7
feat: give every Hermes instance a Home thread for proactive delivery
bmdavis419 Jul 26, 2026
288fe5d
fix: a session ending a turn erased the thread's latest turn
bmdavis419 Jul 27, 2026
52035b9
fix: Home threads showed another provider's composer controls
bmdavis419 Jul 27, 2026
a8e904d
feat: make agent projects first-class in the v2 sidebar
bmdavis419 Jul 27, 2026
d66bc53
fix: migration 038 skipped threads with a dangling pending turn-start
bmdavis419 Jul 27, 2026
0790133
fix: agent projects reached the project-actions dialog via the scope …
bmdavis419 Jul 27, 2026
1d23dec
refactor: small cleanups from review of the Hermes UI commits
bmdavis419 Jul 27, 2026
782ebb1
Merge origin/main into experiment/hermes-provider-ui
bmdavis419 Jul 27, 2026
d1ebcff
feat: delivery headers show relative time, exact date on hover
bmdavis419 Jul 27, 2026
a706e8f
fix: convert branch-added tests off Effect.runPromise for the lint ba…
bmdavis419 Jul 27, 2026
98c36a0
feat: more breathing room between consecutive deliveries
bmdavis419 Jul 27, 2026
2e14225
fix: mobile stand-in projects missing the required agentInstanceId field
bmdavis419 Jul 27, 2026
5b82054
feat: separate Home deliveries like distinct exchanges, not paragraphs
bmdavis419 Jul 27, 2026
e3dc202
style: catchTags over catchTag in the two Hermes handlers
bmdavis419 Jul 27, 2026
9831724
fix: delivery spacing never applied — :first-child matches every virt…
bmdavis419 Jul 27, 2026
d22d5ac
fix: reconnect no longer cancels interactions a live plugin still holds
bmdavis419 Jul 27, 2026
8915a44
feat: use the real Hermes Agent mark instead of the local caduceus
bmdavis419 Jul 27, 2026
285b05e
fix: Home composer chip sits at the normal thread's footer height
bmdavis419 Jul 27, 2026
6e29fe6
docs: plan Hermes media support — send and render files both directions
bmdavis419 Jul 27, 2026
a9b34ea
fix: Home model chip matches the Button trigger's weight and border i…
bmdavis419 Jul 27, 2026
b76f8c8
chore(hermes-plugin): drop duplicated definitions and add ruff config
bmdavis419 Jul 27, 2026
4659e0d
ci: run hermes plugin lint + tests
bmdavis419 Jul 27, 2026
a2f6f78
fix: make migrations 036/037 idempotent like 035
bmdavis419 Jul 27, 2026
b4beb1a
feat(contracts): Hermes gateway protocol v4 — media frames and file a…
bmdavis419 Jul 28, 2026
568ed47
feat(server): store and deliver Hermes media end to end
bmdavis419 Jul 28, 2026
6cb4f53
feat(hermes-plugin): protocol v4 — send and receive media
bmdavis419 Jul 28, 2026
a398258
feat(web): render Hermes media natively in the timeline
bmdavis419 Jul 28, 2026
6998f16
feat(web): send any file to Hermes — drag, paste, and a paperclip picker
bmdavis419 Jul 28, 2026
2e08065
fix(web): expired-asset media degrades to a download card, not a dead…
bmdavis419 Jul 28, 2026
ae8c0ca
fix(hermes-plugin): reply media reached T3 only while its turn was live
bmdavis419 Jul 28, 2026
77ff1ee
fix(client-runtime): live media deliveries kept their file only after…
bmdavis419 Jul 28, 2026
3d04595
fix(hermes-plugin): an image-only reply left its turn running forever
bmdavis419 Jul 28, 2026
3bc3f45
fix: harden media delivery durability per review findings
bmdavis419 Jul 28, 2026
c9a118c
fix: close enrollment/revocation races and an unkillable correlator send
bmdavis419 Jul 28, 2026
affd4bf
fix: final review-triage batch — five real findings, eight dispositioned
bmdavis419 Jul 28, 2026
5a76272
Merge origin/main into experiment/hermes-provider-ui
bmdavis419 Jul 28, 2026
a5400f8
style: format HermesAdapter.test.ts
bmdavis419 Jul 28, 2026
7afd833
fix(web): hide the enrollment command's horizontal scrollbar
bmdavis419 Jul 28, 2026
ebe7bc4
fix(hermes-plugin): compensate in-process for core's send_message med…
bmdavis419 Jul 28, 2026
31113df
fix(web): a turn's media messages no longer hide behind "Worked for …"
bmdavis419 Jul 28, 2026
73ddc93
feat(web): lock agent-bound projects to their Hermes agent
bmdavis419 Jul 28, 2026
060dce3
style: format hermes-t3-gateway README
bmdavis419 Jul 28, 2026
3b114a4
fix(web): agent-project lock respects model-level thread bindings
bmdavis419 Jul 28, 2026
8f6bbab
Merge origin/main into experiment/hermes-provider-ui
bmdavis419 Jul 29, 2026
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
19 changes: 19 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,22 @@ jobs:

- name: Exercise release-only workflow steps
run: node scripts/release-smoke.ts

hermes_plugin:
name: Hermes Plugin
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 5
steps:
- name: Checkout
uses: actions/checkout@v6

- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Lint
run: pipx run ruff check integrations/hermes-t3-gateway

- name: Test
run: python -m unittest discover -s integrations/hermes-t3-gateway/tests -v
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,5 @@ node_modules/
*.log
.env*
!.env.example
__pycache__/
*.py[cod]
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ function makeProject(
return {
workspaceRoot: `/workspaces/${input.id}`,
repositoryIdentity: null,
agentInstanceId: null,
defaultModelSelection: null,
scripts: [],
createdAt: "2026-06-01T00:00:00.000Z",
Expand Down
5 changes: 4 additions & 1 deletion apps/mobile/src/features/home/HomeRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as Order from "effect/Order";
import { useNavigation } from "@react-navigation/native";
import { useEffect, useMemo, useState } from "react";

import { selectWorkspaceProjects } from "../../lib/repositoryGroups";
import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
import { useProjects, useThreadShells } from "../../state/entities";
import { usePendingNewTasks } from "../../state/use-pending-new-tasks";
Expand Down Expand Up @@ -59,10 +60,12 @@ export function HomeRouteScreen() {
} = useHomeListOptions(availableEnvironmentIds);
const selectedEnvironmentId = listOptions.selectedEnvironmentId;
const [selectedProjectKey, setSelectedProjectKey] = useState<string | null>(null);
// Filter menu = picker: agent projects are excluded here, while the thread
// list below still receives the full list so agent threads stay visible.
const projectFilterOptions = useMemo(
() =>
buildHomeProjectScopes({
projects,
projects: selectWorkspaceProjects(projects),
environmentId: selectedEnvironmentId,
projectGroupingMode: listOptions.projectGroupingMode,
}).map((scope) => ({
Expand Down
5 changes: 4 additions & 1 deletion apps/mobile/src/features/home/HomeScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { AppText as Text } from "../../components/AppText";
import { EmptyState } from "../../components/EmptyState";
import type { WorkspaceState } from "../../state/workspaceModel";
import type { SavedRemoteConnection } from "../../lib/connection";
import { selectWorkspaceProjects } from "../../lib/repositoryGroups";
import { scopedProjectKey } from "../../lib/scopedEntities";
import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
Expand Down Expand Up @@ -773,7 +774,9 @@ export function HomeScreen(props: HomeScreenProps) {
const shouldShowConnectionStatus = shouldShowWorkspaceConnectionStatus(props.catalogState);
const emptyState = deriveEmptyState({
catalogState: props.catalogState,
projectCount: props.projects.length,
// "No projects found" counts real workspaces: a synthetic agent project
// is not something the user added, so it must not suppress the hint.
projectCount: selectWorkspaceProjects(props.projects).length,
});
const connectionStatus =
shouldShowConnectionStatus && Platform.OS !== "ios" ? (
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/features/home/homeListItems.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ function makeProject(id: string, title: string): EnvironmentProject {
title,
workspaceRoot: `/workspaces/${id}`,
repositoryIdentity: null,
agentInstanceId: null,
defaultModelSelection: null,
scripts: [],
createdAt: "2026-06-01T00:00:00.000Z",
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/features/home/homeThreadList.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ function makeProject(
return {
workspaceRoot: `/workspaces/${input.id}`,
repositoryIdentity: null,
agentInstanceId: null,
defaultModelSelection: null,
scripts: [],
createdAt: "2026-06-01T00:00:00.000Z",
Expand Down
8 changes: 8 additions & 0 deletions apps/mobile/src/features/home/homeThreadList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ export function buildHomeProjectScopes(input: {
readonly environmentId: EnvironmentId | null;
readonly projectGroupingMode: SidebarProjectGroupingMode;
}): ReadonlyArray<HomeProjectScope> {
// NOTE: agent projects are deliberately *not* filtered here. This builder
// feeds both the project filter menu (a picker — callers pass an
// agent-free list, see `selectWorkspaceProjects`) and the thread list
// itself. Excluding agent projects here would silently drop every agent
// thread from mobile, which has no Agents surface to show them in instead.
const projects = input.projects.filter(
(project) => input.environmentId === null || project.environmentId === input.environmentId,
);
Expand Down Expand Up @@ -305,6 +310,9 @@ export function buildHomeThreadGroups(input: {
workspaceRoot:
pendingTask.creation.projectCwd ?? String(pendingTask.creation.projectId),
repositoryIdentity: null,
// A queued task always targets a real workspace; agent projects
// never enqueue through this path.
agentInstanceId: null,
defaultModelSelection: null,
scripts: [],
createdAt: pendingTask.message.createdAt,
Expand Down
5 changes: 4 additions & 1 deletion apps/mobile/src/features/projects/AddProjectScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import * as Cause from "effect/Cause";
import * as Order from "effect/Order";
import { AsyncResult } from "effect/unstable/reactivity";
import { cn } from "../../lib/cn";
import { selectWorkspaceProjects } from "../../lib/repositoryGroups";

import { useProjects, useServerConfigs } from "../../state/entities";
import { filesystemEnvironment } from "../../state/filesystem";
Expand Down Expand Up @@ -496,7 +497,9 @@ export function AddProjectSourceScreen() {
function useCreateProject(environment: EnvironmentOption | null) {
const navigation = useNavigation();
const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false });
const projects = useProjects();
// Dedupe against real projects only: an agent's synthetic directory should
// never make "add project" bounce the user into an agent thread.
const projects = selectWorkspaceProjects(useProjects());

return useCallback(
async (workspaceRoot: string) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const projects: ReadonlyArray<EnvironmentProject> = [
title: "T3 Code",
workspaceRoot: "/workspace/t3code",
repositoryIdentity: null,
agentInstanceId: null,
defaultModelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
scripts: [],
createdAt: "2026-07-16T08:00:00.000Z",
Expand All @@ -25,6 +26,7 @@ const projects: ReadonlyArray<EnvironmentProject> = [
title: "React",
workspaceRoot: "/workspace/react",
repositoryIdentity: null,
agentInstanceId: null,
defaultModelSelection: null,
scripts: [],
createdAt: "2026-07-16T08:00:00.000Z",
Expand Down
16 changes: 14 additions & 2 deletions apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
} from "../../state/use-composer-drafts";
import { useProjects } from "../../state/entities";
import { deriveThreadTitleFromPrompt } from "../../lib/projectThreadStartTurn";
import { selectWorkspaceProjects } from "../../lib/repositoryGroups";
import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration";
import { enqueueThreadOutboxMessage, removeThreadOutboxMessage } from "../../state/thread-outbox";
import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry";
Expand Down Expand Up @@ -73,7 +74,12 @@ export function NewTaskDraftScreen(props: {
/** Durable native share inbox item to merge into this project draft. */
readonly incomingShareId?: string;
}) {
const projects = useProjects();
// Filtered wholesale: mobile has no agent surface, so a route (or share
// reservation) pointing at a synthetic agent project should fall through to
// "return to the project picker" rather than open a draft against an empty
// agent directory.
const allProjects = useProjects();
const projects = useMemo(() => selectWorkspaceProjects(allProjects), [allProjects]);
const createProjectThread = useCreateProjectThread();
const flow = useNewTaskFlow();
const navigation = useNavigation();
Expand Down Expand Up @@ -264,7 +270,12 @@ export function NewTaskDraftScreen(props: {
return;
}

if (projects.length > 0) {
// Guard on the *unfiltered* catalog. `projects` drops synthetic agent
// projects, so an agent-only environment is indistinguishable from a
// catalog that has not loaded yet — and a route pointing at an agent
// project then found no directProject, skipped this branch, and left the
// screen on "Loading task" with nothing left to resolve it.
if (allProjects.length > 0) {
// Never fall through to the flow provider's temporary first-project
// default. Return to the picker with the share id intact so the user
// can choose an available destination.
Expand All @@ -284,6 +295,7 @@ export function NewTaskDraftScreen(props: {

navigation.dispatch(StackActions.replace("NewTask"));
}, [
allProjects,
logicalProjects,
projects,
props.initialProjectRef,
Expand Down
11 changes: 9 additions & 2 deletions apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { ControlPillMenu } from "../../components/ControlPill";
import { SymbolView } from "../../components/AppSymbol";
import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
import { NativeStackScreenOptions } from "../../native/StackHeader";
import { selectWorkspaceProjects } from "../../lib/repositoryGroups";
import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities";
import { useThemeColor } from "../../lib/useThemeColor";
import { useProjects, useThreadShells } from "../../state/entities";
Expand Down Expand Up @@ -219,13 +220,19 @@ function ThreadNavigationSidebarPane(
}),
[options.projectGroupingMode, options.selectedEnvironmentId, projects],
);
// The scope list above stays complete so agent threads remain visible and
// scopable; only the *picker* built from it drops agent projects.
const projectFilterOptions = useMemo(
() =>
projectScopes.map((scope) => ({
buildHomeProjectScopes({
projects: selectWorkspaceProjects(projects),
environmentId: options.selectedEnvironmentId,
projectGroupingMode: options.projectGroupingMode,
}).map((scope) => ({
key: scope.key,
label: scope.title,
})),
[projectScopes],
[options.projectGroupingMode, options.selectedEnvironmentId, projects],
);
const projectTitleByProjectKey = useMemo(
() =>
Expand Down
12 changes: 10 additions & 2 deletions apps/mobile/src/features/threads/new-task-flow-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import type { TurnCommandMetadata } from "../../lib/commandMetadata";
import type { DraftComposerImageAttachment } from "../../lib/composerImages";
import type { ModelOption, ProviderGroup } from "../../lib/modelOptions";
import { buildModelOptions, groupByProvider } from "../../lib/modelOptions";
import { groupProjectsByRepository } from "../../lib/repositoryGroups";
import { groupProjectsByRepository, selectWorkspaceProjects } from "../../lib/repositoryGroups";
import { scopedProjectKey } from "../../lib/scopedEntities";
import { appAtomRegistry } from "../../state/atom-registry";
import {
Expand Down Expand Up @@ -168,7 +168,12 @@ type NewTaskFlowContextValue = {
const NewTaskFlowContext = React.createContext<NewTaskFlowContextValue | null>(null);

export function NewTaskFlowProvider(props: React.PropsWithChildren) {
const projects = useProjects();
// Every use of this list is part of choosing a destination for a new task,
// so synthetic agent projects are excluded once, here, rather than at each
// of the environment/project selection sites below. Memoized because the
// downstream grouping/selection memos take it as a dependency.
const allProjects = useProjects();
const projects = useMemo(() => selectWorkspaceProjects(allProjects), [allProjects]);
const threads = useThreadShells();
const { savedConnectionsById } = useSavedRemoteConnections();

Expand Down Expand Up @@ -259,6 +264,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
// fabricated path.
workspaceRoot: creation.projectCwd ?? "",
repositoryIdentity: null,
// A queued task always targets a real workspace; agent projects never
// enqueue through this path.
agentInstanceId: null,
defaultModelSelection: editingPendingTask.modelSelection ?? null,
scripts: [],
createdAt: editingPendingTask.createdAt,
Expand Down
50 changes: 49 additions & 1 deletion apps/mobile/src/lib/repositoryGroups.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { describe, expect, it } from "vite-plus/test";

import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";

import { groupProjectsByRepository } from "./repositoryGroups";
import { groupProjectsByRepository, isAgentProject } from "./repositoryGroups";
import { EnvironmentProject, EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";

function makeProject(
Expand All @@ -11,6 +11,7 @@ function makeProject(
return {
workspaceRoot: `/workspaces/${input.id}`,
repositoryIdentity: null,
agentInstanceId: null,
defaultModelSelection: null,
scripts: [],
createdAt: "2026-04-01T00:00:00.000Z",
Expand Down Expand Up @@ -190,3 +191,50 @@ describe("groupProjectsByRepository", () => {
expect(groups[0]?.subtitle).toBeNull();
});
});

describe("agent project exclusion", () => {
const environmentId = EnvironmentId.make("env-local");
const agentInstanceId = ProviderInstanceId.make("hermes_workstation");

it("keeps a project with no reported agent instance", () => {
// Older snapshots omit `agentInstanceId` entirely; that must read as an
// ordinary project, not as an agent.
expect(
isAgentProject(
makeProject({ environmentId, id: ProjectId.make("project-legacy"), title: "Legacy" }),
),
).toBe(false);
});

it("identifies a synthetic agent project", () => {
expect(
isAgentProject(
makeProject({
environmentId,
id: ProjectId.make("project-agent"),
title: "Workstation",
agentInstanceId,
}),
),
).toBe(true);
});

it("omits agent projects from repository groups", () => {
const workspace = makeProject({
environmentId,
id: ProjectId.make("project-real"),
title: "Real",
});
const agent = makeProject({
environmentId,
id: ProjectId.make("project-agent"),
title: "Workstation",
agentInstanceId,
});

const groups = groupProjectsByRepository({ projects: [workspace, agent], threads: [] });

expect(groups).toHaveLength(1);
expect(groups[0]?.projects[0]?.project.id).toBe(workspace.id);
});
});
26 changes: 25 additions & 1 deletion apps/mobile/src/lib/repositoryGroups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,34 @@ function deriveProjectLatestActivity(
return latestThread?.updatedAt ?? latestThread?.createdAt ?? project.updatedAt;
}

export function groupProjectsByRepository(input: {
/**
* True for the synthetic project row backing an agent instance
* (`OrchestrationProjectShell.agentInstanceId`). Such a row is a legal project
* with a real — but empty — workspace directory, created only so an agent
* thread can satisfy `project_id NOT NULL`. It is never a workspace the user
* chose, so it must not appear in any surface that offers a project to pick.
*/
export function isAgentProject(project: Pick<EnvironmentProject, "agentInstanceId">): boolean {
// `!= null` on purpose: the contract decodes a default of `null`, but an
// older snapshot (or a partial fixture) can omit the field entirely, and
// "not reported" must mean "ordinary project", never "agent".
return project.agentInstanceId != null;
}

/** Drop synthetic agent projects from a list destined for a picker. */
export function selectWorkspaceProjects<
TProject extends Pick<EnvironmentProject, "agentInstanceId">,
>(projects: ReadonlyArray<TProject>): TProject[] {
return projects.filter((project) => !isAgentProject(project));
}

export function groupProjectsByRepository(rawInput: {
readonly projects: ReadonlyArray<EnvironmentProject>;
readonly threads: ReadonlyArray<EnvironmentThreadShell>;
}): ReadonlyArray<RepositoryGroup> {
// This grouping only ever feeds the "choose a project" screens, so the
// exclusion lives here rather than at each call site.
const input = { ...rawInput, projects: selectWorkspaceProjects(rawInput.projects) };
const threadsByProjectKey = new Map<string, EnvironmentThreadShell[]>();

for (const thread of input.threads) {
Expand Down
9 changes: 8 additions & 1 deletion apps/mobile/src/widgets/AgentActivity.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export type AgentActivityPhase =
| "running"
| "waiting_for_approval"
| "waiting_for_input"
| "notification"
| "completed"
| "failed"
| "stale";
Expand Down Expand Up @@ -81,6 +82,8 @@ export function AgentActivity(
return isLightScheme ? "#d97706" : "#fcd34d"; // amber-600 / amber-300
case "waiting_for_input":
return isLightScheme ? "#4f46e5" : "#a5b4fc"; // indigo-600 / indigo-300
case "notification":
return isLightScheme ? "#7c3aed" : "#c4b5fd"; // violet-600 / violet-300
case "failed":
return isLightScheme ? "#dc2626" : "#fca5a5"; // red-600 / red-300
case "completed":
Expand All @@ -97,7 +100,9 @@ export function AgentActivity(
const phasePriority = (phase: AgentActivityPhase): number => {
if (phase === "waiting_for_approval" || phase === "waiting_for_input") return 0;
if (phase === "failed") return 1;
if (phase === "running" || phase === "starting") return 2;
// A proactive delivery is unread news, so it outranks finished/stale rows
// but never a human who is actually blocked.
if (phase === "running" || phase === "starting" || phase === "notification") return 2;
return 3;
};
const ordered = [...props.activities].sort(
Expand Down Expand Up @@ -163,6 +168,8 @@ export function AgentActivity(
return "exclamationmark.circle.fill";
case "waiting_for_input":
return "questionmark.circle.fill";
case "notification":
return "bell.badge.fill";
case "failed":
return "xmark.octagon.fill";
case "completed":
Expand Down
Loading
Loading