Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
5cc46b7
fix(mobile): drop the jump-to-latest pill from the chat
ashrafchowdury Sep 15, 2026
cfc7e8e
feat(chat): the /m transcript fades only at clipped edges
ashrafchowdury Sep 15, 2026
76d73bf
fix(mobile): 8px more above the first message
ashrafchowdury Sep 16, 2026
20ec381
fix(sessions-ui): fetch the tab rail's open tabs by id
ashrafchowdury Sep 15, 2026
c67e455
feat(sessions-ui): session history popover reads like the sidebar
ashrafchowdury Sep 15, 2026
41294ff
fix(sessions-ui): tab strip polish
ashrafchowdury Sep 15, 2026
384faa1
fix(entity-ui): agent chip wears the rail's Robot glyph, 4px radius
ashrafchowdury Sep 15, 2026
2d17efc
fix(sessions-ui): status dots read as their state
ashrafchowdury Sep 15, 2026
e43d261
fix(mobile): the browser title catches a session's name
ashrafchowdury Sep 15, 2026
ca6e097
fix(sessions): archiving a session drops its pin
ashrafchowdury Sep 15, 2026
c07f08b
fix(mobile): skeletons drawn on the real geometry, from the shared block
ashrafchowdury Sep 15, 2026
0c20ba4
fix(mobile): skeleton rail matches the transcript's top inset
ashrafchowdury Sep 16, 2026
f3cdca0
fix(mobile): the workspace follows the agent's latest revision
ashrafchowdury Sep 16, 2026
1a34eab
feat(mobile): landing on a session focuses the composer
ashrafchowdury Sep 16, 2026
f3d64f7
fix(chat): Enter is a newline on a touch-only device
ashrafchowdury Sep 16, 2026
f0d28a3
feat(mobile): Home reopens on the agent a chat was last started with
ashrafchowdury Sep 16, 2026
e882a5a
fix(sessions-ui): an unlisted tab carries the tab verbs; chips never …
ashrafchowdury Sep 16, 2026
688d93d
fix(mobile): the user's turn renders as markdown, as the desktop bubb…
ashrafchowdury Sep 16, 2026
a05041f
fix(mobile): the send glyph is ink on the dark theme's yellow
ashrafchowdury Sep 16, 2026
8910361
fix(mobile): the user turn's markdown takes the fence icons from its …
ashrafchowdury Sep 16, 2026
6419f28
test(frontend): ProjectWatch mock carries the committed-revision inva…
ashrafchowdury Sep 16, 2026
48898ea
fix(sessions): tab rows stay inside the requested set; the rail never…
ashrafchowdury Sep 17, 2026
4218969
fix(mobile): Home remembers the agent only once the chat route took t…
ashrafchowdury Sep 17, 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
6 changes: 3 additions & 3 deletions web/mobile/src/components/ContentRail.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type {ReactNode} from "react"
import type {ComponentProps} from "react"

import {CHAT_COLUMN} from "@agenta/chat/assets"

Expand All @@ -15,6 +15,6 @@ import {cn} from "@/lib/utils"
* — a local literal drifted a step narrower. Screens with a wider composition (Home's two-column
* grid) pass their own cap via className.
*/
export const ContentRail = ({className, children}: {className?: string; children: ReactNode}) => (
<div className={cn(CHAT_COLUMN, className)}>{children}</div>
export const ContentRail = ({className, ...props}: ComponentProps<"div">) => (
<div className={cn(CHAT_COLUMN, className)} {...props} />
)
14 changes: 14 additions & 0 deletions web/mobile/src/features/chat/AssistantMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,17 @@ export const AssistantMarkdown = ({
/>
)
}

/**
* The user's own turn, through the same pipeline — no typewriter, nothing streams. The composer
* serialises a draft as markdown, escapes and all, so a literal render showed `hi\_name` for a
* typed `hi_name`; the desktop bubble has always rendered markdown, and this matches it.
*/
export const UserMarkdown = ({text}: {text: string}) => (
<ChatMarkdown
baseClassName={proseClassName}
content={text}
streaming={false}
useLinkResolver={useDriveLinkResolver}
/>
)
8 changes: 3 additions & 5 deletions web/mobile/src/features/chat/ChatScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
createTurnViewModelCache,
getPendingApprovals,
} from "@agenta/chat/model"
import {ChatJumpToLatest} from "@agenta/ui/components/presentational"
import {useAtomValue} from "jotai"

import {ContentRail} from "@/components/ContentRail"
Expand Down Expand Up @@ -184,10 +183,10 @@
// Both factories take no argument, so the linter reads `sessionId` as unused. It is the point:
// the dep is what discards the previous session's cache on a screen that never remounts.

const executedFor = useMemo(() => createExecutedToolIdentityCache(), [sessionId])

Check warning on line 186 in web/mobile/src/features/chat/ChatScreen.tsx

View workflow job for this annotation

GitHub Actions / TypeScript lint

React Hook useMemo has an unnecessary dependency: 'sessionId'. Either exclude it or remove the dependency array
// Without this every view model is a fresh object per poll, so TurnRow's memo never hits.

const turnCache = useMemo(() => createTurnViewModelCache(), [sessionId])

Check warning on line 189 in web/mobile/src/features/chat/ChatScreen.tsx

View workflow job for this annotation

GitHub Actions / TypeScript lint

React Hook useMemo has an unnecessary dependency: 'sessionId'. Either exclude it or remove the dependency array
const turns = useMemo(
() => buildTurnViewModels(messages, {busy: false, executedFor, cache: turnCache}),
[messages, executedFor, turnCache],
Expand All @@ -206,7 +205,7 @@
body = <ChatEmpty />
} else {
body = (
<ContentRail className="flex grow flex-col gap-3 p-4 pb-[calc(1rem+env(safe-area-inset-bottom))]">
<ContentRail className="flex grow flex-col gap-3 p-4 pt-6 pb-[calc(1rem+env(safe-area-inset-bottom))]">
<TranscriptTurns
turns={visibleTurns}
sessionId={sessionId}
Expand All @@ -222,9 +221,8 @@
<ScreenScaffold
scrollRef={autoScroll.ref}
onScroll={autoScroll.onScroll}
scrollOverlay={
<ChatJumpToLatest show={autoScroll.showJump} onClick={autoScroll.jumpToLatest} />
}
// Same edge fades the live conversation wears.
scrollStyle={{maskImage: autoScroll.edgeMask, WebkitMaskImage: autoScroll.edgeMask}}
embedded={embedded}
header={
<>
Expand Down
67 changes: 44 additions & 23 deletions web/mobile/src/features/chat/LiveConversation.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import {useCallback, useEffect, useMemo, useRef, useState} from "react"

import {
EDGE_FADE_MASK,
jumpGateOpen,
latestTurnId,
resolveStopExecution,
shouldShowStopControl,
Expand Down Expand Up @@ -32,11 +30,12 @@
} from "@agenta/chat/model"
import {getSessionTurnId} from "@agenta/chat/state"
import {cancelSessionExecution} from "@agenta/entities/session"
import {invalidateAgentCommittedRevisionCache} from "@agenta/entities/workflow"
import {AgentIntroCard} from "@agenta/entity-ui/agent"
import {SecretRequestDock} from "@agenta/entity-ui/clientTools"
import {isOnScreen, isOverlayOpen} from "@agenta/shared/utils"
import {message, modal} from "@agenta/ui/app-message"
import {ChatBubble, ChatJumpToLatest} from "@agenta/ui/components/presentational"
import {ChatBubble} from "@agenta/ui/components/presentational"
import type {RichChatInputHandle} from "@agenta/ui/rich-chat-input"
import {isAltChord} from "@agenta/ui/shortcuts"
import {Button} from "@agenta/ui/ui"
Expand All @@ -52,6 +51,7 @@
import {livenessQueryKey, useLivenessUpdatedAt} from "../sessions/useLivenessPoll"

import {ApprovalDock} from "./ApprovalDock"
import {committedRevisionIds} from "./committedRevisionIds"
import {Composer} from "./Composer"
import {ConnectModelStrip} from "./ConnectModelStrip"
import {MODEL_KEY_WAIT_LIMIT_MS, pendingTaskDecision} from "./pendingTaskPolicy"
Expand All @@ -78,6 +78,12 @@
* mobile's detached resume path; after it fires, the records change and the watch relay's
* `revalidate()` folds the resumed turn in.
*/
/** The caret is in a field that is not ours — leave it there. */
const isTypingElsewhere = (active: Element | null): boolean =>
active instanceof HTMLElement &&
active !== document.body &&
(active.isContentEditable || active.tagName === "INPUT" || active.tagName === "TEXTAREA")

export const LiveConversation = ({
entityId,
sessionId,
Expand Down Expand Up @@ -152,13 +158,27 @@
conversation.adoptRevision(next)
pinRevision(next)
},
[conversation.adoptRevision, pinRevision],

Check warning on line 161 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],
[conversation.messages],
)

// The agent committing itself: the stream carries a one-way `data-committed-revision` part.
// Follow it — pin the workspace and retarget the next send — and drop the latest-revision
// caches, or the config pane and the version chip keep showing the revision it replaced.
// The desktop does the same in its own host hook; the shared engine leaves it to the skin.
const committedSeenRef = useRef<Set<string>>(new Set())
useEffect(() => {
for (const revisionId of committedRevisionIds(conversation.messages)) {
if (committedSeenRef.current.has(revisionId)) continue
committedSeenRef.current.add(revisionId)
invalidateAgentCommittedRevisionCache()
if (revisionId !== entityId) adoptSecretRevision(revisionId)
}
}, [adoptSecretRevision, conversation.messages, entityId])

// The connect-model gate — desktop parity. The engine deliberately leaves this to the skin
// (`useAgentConversation` says so): a keyless project must be told to add a key BEFORE the
// send, not shown a raw 422 after it. Blocks the composer, holds the parked task, and raises
Expand Down Expand Up @@ -203,6 +223,19 @@
input?.focus()
}, [cancelEdit])

// Landing on a session — a new one from `+` or the shortcut, or a switch to an existing one —
// puts the caret in the composer, once it can take input. Not while the caret is already in
// some other field: a self-commit remounts this screen mid-edit in the config pane.
const disabled = conversation.isHydrating || modelBlocked
useEffect(() => {
if (disabled) return
const frame = requestAnimationFrame(() => {
if (isTypingElsewhere(document.activeElement)) return
composerRef.current?.focus()
})
return () => cancelAnimationFrame(frame)
}, [disabled, sessionId])

// Keep Home tasks session-scoped until admission; failures require an explicit retry.
const pendingTasks = useAtomValue(pendingTasksAtom)
const pendingTask = pendingTasks[sessionId]
Expand Down Expand Up @@ -525,15 +558,6 @@
})
const secretDockOpen =
!streamingHere && !stopping && !conversation.stopped && Boolean(pendingSecret)
// A docked gate holds the jump pill back — same rule, same reasons, as the desktop. This
// surface has no question-form dock yet, so approvals, connect, and secret cards gate it.
const gateOpen =
jumpGateOpen({
approvals: pendingApprovals.length,
elicitationOpen: false,
connectionOpen: connects.open,
}) || secretDockOpen

// Rewind: re-run the conversation from a turn. The hook only SCANS (it never opens dialogs),
// so the warning about tools that already ran, and putting a rewound user message back into
// the composer, are this surface's job — same division the desktop uses. `composerRef` is
Expand Down Expand Up @@ -602,7 +626,7 @@
body = <ChatLoading />
} else {
body = (
<ContentRail className="flex grow flex-col gap-3 p-4 pb-[calc(1rem+env(safe-area-inset-bottom))]">
<ContentRail className="flex grow flex-col gap-3 p-4 pt-6 pb-[calc(1rem+env(safe-area-inset-bottom))]">
{/* A held or failed Home task stays visible until accepted. */}
{heldTaskText ? (
<div className={`${mobileTurnRowClass} justify-end`}>
Expand Down Expand Up @@ -656,16 +680,13 @@
<ScreenScaffold
scrollRef={autoScroll.ref}
onScroll={autoScroll.onScroll}
scrollOverlay={
<ChatJumpToLatest
show={autoScroll.showJump && !gateOpen}
onClick={autoScroll.jumpToLatest}
/>
}
embedded={embedded}
// The top edge fades as a MASK, exactly as the desktop transcript does — content
// dissolves under the tab bar instead of being cut by a hard line.
scrollStyle={{maskImage: EDGE_FADE_MASK, WebkitMaskImage: EDGE_FADE_MASK}}
// Edge fades as a MASK, only where content is clipped — the Home list's rule, so
// the first message reads crisp at the top and the bottom says there is more.
scrollStyle={{
maskImage: autoScroll.edgeMask,
WebkitMaskImage: autoScroll.edgeMask,
}}
footer={
<div className="relative">
{/* What you have lined up stays visible while a gate is open: the queued
Expand Down Expand Up @@ -808,7 +829,7 @@
onSteer={({text, parts, stagedFiles}) =>
conversation.steer({text, parts, stagedFiles})
}
disabled={conversation.isHydrating || modelBlocked}
disabled={disabled}
placeholder={
modelBlocked ? "Connect a model to start chatting…" : undefined
}
Expand Down
12 changes: 8 additions & 4 deletions web/mobile/src/features/chat/SessionHistoryMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {useState} from "react"
import type {SessionRowVm} from "@agenta/sessions/row"
import {SessionCardList} from "@agenta/sessions-ui"
import {Button, Popover, PopoverContent, PopoverTrigger, SimpleTooltip} from "@agenta/ui/ui"
import {History} from "lucide-react"
import {ChatsCircleIcon} from "@phosphor-icons/react"
import {useRouter} from "next/router"

import {useSessionRowMenu} from "../sessions/useSessionRowMenu"
Expand Down Expand Up @@ -49,12 +49,13 @@ export const SessionHistoryMenu = ({
aria-label="Session history"
className="h-7 w-7 shrink-0 p-0"
>
<History size={14} />
{/* The rail's own Sessions glyph, so the button reads as that list. */}
<ChatsCircleIcon size={14} />
</Button>
</PopoverTrigger>
</SimpleTooltip>
<PopoverContent align="end" className="flex w-[320px] flex-col gap-1 p-2">
<span className="px-2 pt-1 text-xs font-medium">Session history</span>
<span className="px-3 pt-1 text-xs font-medium">Session history</span>
{/* Capped and scrolled: the strip already shows the recent ones, so this is the
tail, and an agent with a hundred sessions must not grow the popover. */}
{/* The surface is restated here, not left to PopoverContent, because the sticky
Expand All @@ -66,7 +67,10 @@ export const SessionHistoryMenu = ({
policy={{origin: "exclude-trigger", expansions: []}}
limit={30}
withPinned
alwaysShowPin
// The rail's one-line rows, so the popover reads as the sidebar does —
// the time kept, the subtitle dropped, the pin on hover beside the name.
density="compact"
activeRowId={activeSessionId}
emptyText="No sessions with this agent yet."
onOpenRow={openRow}
menuFor={menu.menuFor}
Expand Down
10 changes: 3 additions & 7 deletions web/mobile/src/features/chat/TurnRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import {useAtomValue, useSetAtom} from "jotai"
import {cn} from "@/lib/utils"

import {AnswerReveal} from "./AnswerReveal"
import {AssistantMarkdown} from "./AssistantMarkdown"
import {AssistantMarkdown, UserMarkdown} from "./AssistantMarkdown"
import {continuationRetryAction} from "./continuationRetry"
import {isLiveTextItem} from "./markdownStream"
import {RunErrorCallout} from "./RunErrorCallout"
Expand Down Expand Up @@ -165,12 +165,8 @@ const TurnRowInner = ({
{turn.items.map((item) => {
if (item.kind !== "part" || item.part.type !== "text") return null
if (!(item.part.text ?? "").trim()) return null
// What the user typed renders literally (desktop parity).
return (
<p key={item.index} className="m-0 whitespace-pre-wrap break-words text-xs">
{item.part.text}
</p>
)
// Markdown, as the desktop bubble renders it: the composer's export is markdown.
return <UserMarkdown key={item.index} text={item.part.text ?? ""} />
})}
</div>
) : (
Expand Down
17 changes: 17 additions & 0 deletions web/mobile/src/features/chat/committedRevisionIds.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/**
* Revision ids the agent committed in this transcript, in stream order, one per part. The
* backend emits a one-way `data-committed-revision` part on the stream whenever the agent
* commits itself, whether the tool asked first or ran directly.
*/
export const committedRevisionIds = (messages: {parts: unknown[]}[]): string[] => {
const ids: string[] = []
for (const message of messages) {
for (const part of message.parts) {
const candidate = part as {type?: string; data?: {revisionId?: string}}
if (candidate.type !== "data-committed-revision") continue
const revisionId = candidate.data?.revisionId
if (revisionId && !ids.includes(revisionId)) ids.push(revisionId)
}
}
return ids
}
Loading
Loading