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
61 changes: 57 additions & 4 deletions web/mobile/src/features/chat/LiveConversation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,13 @@
type TurnViewModel,
} from "@agenta/chat/model"
import {getSessionTurnId} from "@agenta/chat/state"
import {cancelSessionExecution} from "@agenta/entities/session"
import {
cancelSessionExecution,
dropUnacceptedLocalSessionAtom,
isSessionFresh,
markLocalSessionAcceptedAtom,
registerLocalSessionAtom,
} from "@agenta/entities/session"
import {invalidateAgentCommittedRevisionCache} from "@agenta/entities/workflow"
import {AgentIntroCard} from "@agenta/entity-ui/agent"
import {SecretRequestDock} from "@agenta/entity-ui/clientTools"
Expand Down Expand Up @@ -145,13 +151,21 @@
},
[restoreAttachments, setRejections],
)

// The rail row for a fresh session follows its first send: admitted keeps it until the server
// lists the session, rejected or refused drops it (nothing will ever list that session).
const registerLocalSession = useSetAtom(registerLocalSessionAtom)
const markLocalSessionAccepted = useSetAtom(markLocalSessionAcceptedAtom)
const dropUnacceptedLocalSession = useSetAtom(dropUnacceptedLocalSessionAtom)
const conversation = useAgentConversation({
entityId,
sessionId,
sharedReaderAdvertised: sharedReader,
sharedReaderRunning: running,
sharedReaderLivenessUpdatedAt: livenessUpdatedAt,
restoreRefusedSend,
onSendAccepted: () => markLocalSessionAccepted(sessionId),
onSendFailed: () => dropUnacceptedLocalSession(sessionId),
})
const canEditSecrets = useProjectPermission(projectId, "edit_secret")
const pinRevision = useSetAtom(selectedRevisionAtomFamily(sessionId))
Expand All @@ -160,7 +174,7 @@
conversation.adoptRevision(next)
pinRevision(next)
},
[conversation.adoptRevision, pinRevision],

Check warning on line 177 in web/mobile/src/features/chat/LiveConversation.tsx

View workflow job for this annotation

GitHub Actions / TypeScript lint

React Hook useCallback has a missing dependency: 'conversation'. Either include it or remove the dependency array
)
const pendingSecret = useMemo(
() => getPendingSecretInteractions(conversation.messages)[0],
Expand Down Expand Up @@ -245,11 +259,46 @@
const sendPendingTask = useSetAtom(sendPendingTaskAtom)
const failPendingTask = useSetAtom(failPendingTaskAtom)
const pendingTaskError = pendingTask?.delivery === "failed"
const {
isHydrating,
revalidate,
send: sendToConversation,
stop,
voidPendingResume,
} = conversation
// A fresh session becomes real on the server only once this first message is admitted, which
// can take seconds on a cold runner. Note it locally first, so the rail lists it now (#6776).
const send = useCallback(
async (input: Parameters<typeof sendToConversation>[0]) => {
if (isSessionFresh(sessionId)) {
registerLocalSession({
sessionId,
projectId,
agentId: agentId ?? null,
name: input.text,
})
}
try {
await sendToConversation(input)
} catch (error) {
// The durable path reports this through `onSendFailed` too; this covers the rest.
dropUnacceptedLocalSession(sessionId)
throw error
}
},
[
agentId,
dropUnacceptedLocalSession,
projectId,
registerLocalSession,
sendToConversation,
sessionId,
],
)
// A template create asks for its accounts here, on arrival, instead of on a create surface of
// its own. Holds the first message while it does; declines silently when there is nothing to
// ask, which is every other way into this screen.
const setup = useSessionSetupStep(sessionId)
const {isHydrating, revalidate, send, stop, voidPendingResume} = conversation
useEffect(() => {
if (!pendingTask || pendingTask.delivery) return
const decision = pendingTaskDecision({
Expand Down Expand Up @@ -299,7 +348,11 @@
readerReady: conversation.readerReady,
ownedContinuation: conversation.acceptedRunPending,
})
const showingTurnActivity = streamingHere || remoteTurn.showActivity
// `sendInFlight` covers the gap the other two cannot: the message has left the composer, and
// neither `useChat` (the server-owned path never moves its status) nor liveness (a poll away)
// knows yet. Without it the pulse arrived a runner accept plus a poll after the send (#6778).
const showingTurnActivity =
streamingHere || conversation.sendInFlight || remoteTurn.showActivity
const streamingHereRef = useRef(streamingHere)
streamingHereRef.current = streamingHere
const hitlPendingRef = useRef(conversation.hitlPending)
Expand Down Expand Up @@ -839,7 +892,7 @@
// An open edit rewrites its held message instead of sending. The
// input clears on submit, so the displaced draft goes back after.
if (!conversation.editingId) {
await conversation.send({text, parts, stagedFiles})
await send({text, parts, stagedFiles})
return
}
const draft = await conversation.commitEdit({
Expand Down
71 changes: 71 additions & 0 deletions web/mobile/src/features/nav/localSessionRefs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import {useEffect} from "react"

import {sessionStatusAtomFamily} from "@agenta/chat/state"
import {forgetLocalSessionsAtom, localSessionsAtom} from "@agenta/entities/session"
import {
localSessionRefsAtom,
sidebarServerSessionIdsAtomFamily,
type SessionSidebarRef,
} from "@agenta/navigation"
import {pinnedSessionIdsAtom} from "@agenta/sessions/state"
import {projectIdAtom} from "@agenta/shared/state"
import {atom, useAtomValue, useSetAtom} from "jotai"

/**
* Mobile's binding for `@agenta/navigation`'s local-session seam — the desktop feeds it from its
* playground tab cache; mobile has no tab cache, so it feeds it from the sessions this client
* created and sent into (`localSessionsAtom`, written by the chat screen on a fresh session's
* first send).
*
* The server lists a session only once its first turn is admitted, which on a cold runner takes
* seconds. Until then this is the row's only way into the rail (#6776). `withLocalSessions` lets
* the server row win the moment it exists.
*
* Scoped to the active project: the registry is app-wide, and a row sent from project A must not
* surface in project B's rail after a switch, where the rail would link it under B's URL.
*/
export const localMobileSessionRefsAtom = atom<SessionSidebarRef[]>((get) => {
const projectId = get(projectIdAtom)
const pinned = get(pinnedSessionIdsAtom)
const own = Object.values(get(localSessionsAtom)).filter(
(session) => session.projectId === projectId,
)
return own.map((session) => {
const status = get(sessionStatusAtomFamily(session.sessionId))
return {
id: session.sessionId,
sessionId: session.sessionId,
name: session.name,
// Mobile rows link by session id alone; `appId` is the open target the server resolves.
appId: null,
agentId: session.agentId,
pinned: pinned.includes(session.sessionId),
alive: false,
activityAt: new Date(session.createdAt).toISOString(),
archived: false,
// A chat you typed into is never a trigger run.
isAutomation: false,
running: status === "running",
waiting: status === "awaiting",
}
})
})

/**
* Mirror the derived rows into the package's writable seam, and retire local copies the server
* has caught up with. Mounted with the rail: a rail that is not rendered has nothing to reconcile.
*/
export const useSyncLocalSessionRefs = (scopeId: string) => {
const refs = useAtomValue(localMobileSessionRefsAtom)
const setLocalRefs = useSetAtom(localSessionRefsAtom)
useEffect(() => {
setLocalRefs(refs)
}, [refs, setLocalRefs])

const serverIds = useAtomValue(sidebarServerSessionIdsAtomFamily(scopeId))
const forget = useSetAtom(forgetLocalSessionsAtom)
useEffect(() => {
const known = refs.filter((ref) => serverIds.has(ref.sessionId)).map((ref) => ref.sessionId)
if (known.length) forget(known)
}, [refs, serverIds, forget])
}
4 changes: 4 additions & 0 deletions web/mobile/src/features/nav/useMobileNavItems.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ import {
import {atom, useAtomValue, useSetAtom} from "jotai"
import {unwrap} from "jotai/utils"

import {useSyncLocalSessionRefs} from "./localSessionRefs"

/** The drawer's scope id — its open-groups persistence bucket. */
export const MOBILE_NAV_SCOPE_ID = "mobile-main"

Expand Down Expand Up @@ -109,6 +111,8 @@ const mobileSessionsEntity = defineSidebarEntity<SessionSidebarRef>(
* forking a component.
*/
export const useMobileNavItems = (projectURL: string): SidebarConfig[] => {
// Sessions this client created ride the shared local seam until the server lists them.
useSyncLocalSessionRefs(MOBILE_NAV_SCOPE_ID)
const rawSource = useAtomValue(mobileSessionsEntity.activeSourceAtom)
const loadMoreSessions = useSetAtom(loadMoreSidebarSessionsAtomFamily(MOBILE_NAV_SCOPE_ID))
const groups = useAtomValue(sidebarSessionGroupsAtomFamily(MOBILE_NAV_SCOPE_ID))
Expand Down
70 changes: 70 additions & 0 deletions web/mobile/tests/unit/localSessionRefs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* Mobile's binding of the rail's local-session seam (#6776): a session this client sent into
* shows as a row — under its agent, spinning while it runs — until the server lists it. Only in
* the project it was sent from.
*/
import {sessionStatusAtomFamily, setSessionStatusAtom} from "@agenta/chat/state"
import {registerLocalSessionAtom} from "@agenta/entities/session"
import {projectIdAtom} from "@agenta/shared/state"
import {createStore} from "jotai"
import {describe, expect, it} from "vitest"

import {localMobileSessionRefsAtom} from "@/features/nav/localSessionRefs"

describe("localMobileSessionRefsAtom", () => {
it("lists nothing until a session is registered", () => {
const store = createStore()
store.set(projectIdAtom, "p1")
expect(store.get(localMobileSessionRefsAtom)).toEqual([])
})

it("turns a registered session into a rail row that follows its run status", () => {
const store = createStore()
store.set(projectIdAtom, "p1")
store.set(registerLocalSessionAtom, {
sessionId: "s1",
projectId: "p1",
agentId: "agent-1",
name: "Write a file",
now: Date.UTC(2026, 8, 12, 10, 0, 0),
})
store.set(setSessionStatusAtom, {id: "s1", status: "running"})
expect(store.get(sessionStatusAtomFamily("s1"))).toBe("running")
expect(store.get(localMobileSessionRefsAtom)).toEqual([
{
id: "s1",
sessionId: "s1",
name: "Write a file",
appId: null,
agentId: "agent-1",
pinned: false,
alive: false,
activityAt: "2026-09-12T10:00:00.000Z",
archived: false,
isAutomation: false,
running: true,
waiting: false,
},
])

store.set(setSessionStatusAtom, {id: "s1", status: "awaiting"})
const [row] = store.get(localMobileSessionRefsAtom)
expect(row).toMatchObject({running: false, waiting: true})
})

// The registry is app-wide; a pending row must stay in the project it was sent from
// (#6783 review), or the rail would link it under the other project's URL.
it("keeps a pending row out of another project's rail after a switch", () => {
const store = createStore()
store.set(projectIdAtom, "p1")
store.set(registerLocalSessionAtom, {sessionId: "s1", projectId: "p1", name: "in A"})
store.set(registerLocalSessionAtom, {sessionId: "s2", projectId: "p2", name: "in B"})
expect(store.get(localMobileSessionRefsAtom).map((row) => row.sessionId)).toEqual(["s1"])

store.set(projectIdAtom, "p2")
expect(store.get(localMobileSessionRefsAtom).map((row) => row.sessionId)).toEqual(["s2"])

store.set(projectIdAtom, null)
expect(store.get(localMobileSessionRefsAtom)).toEqual([])
})
})
12 changes: 12 additions & 0 deletions web/packages/agenta-chat/src/assets/pendingSendEchoes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,18 @@ export const retirePendingSendEchoes = (
}

/** Disposable user rows; the id prefix keeps rewind from finding an echo in the AI SDK array. */
/**
* Is a send still on its way to the runner, or streaming as an echo this client owns?
*
* Every echo that is not refused and not parked in the queue is a turn THIS client started and is
* still waiting on: from the moment it leaves the composer until its durable row retires it. That
* is the local "submitted" signal the AI SDK's `status` carries on the direct path, which the
* server-owned send path never sets — without it the working indicator waited for the next
* liveness poll to notice the run (#6778).
*/
export const pendingSendsInFlight = (pending: readonly PendingSendEcho[]): boolean =>
pending.some((echo) => !echo.failed && !echo.parkedInputId)

export const pendingSendEchoMessages = (pending: readonly PendingSendEcho[]): UIMessage[] =>
pending.map(
(item) =>
Expand Down
Loading
Loading