Skip to content
Closed
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
27 changes: 27 additions & 0 deletions packages/app/src/pages/session.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1177,6 +1177,33 @@ export default function Page() {
onStepsExpandedToggle={() =>
setStore("expanded", message.id, (open: boolean | undefined) => !open)
}
onRevert={async (messageID) => {
const sessionID = params.id
if (!sessionID) return
if (status()?.type !== "idle") {
await sdk.client.session.abort({ sessionID }).catch(() => {})
}
// Find the message in the list
const msgs = userMessages()
const idx = msgs.findIndex((m) => m.id === messageID)
if (idx === -1) return

// If there's a next message, revert to that (keeping this message)
// If this is the last message, revert this message itself (classic undo)
const targetID = idx < msgs.length - 1 ? msgs[idx + 1].id : messageID

await sdk.client.session.revert({ sessionID, messageID: targetID })
Comment on lines +1183 to +1195
Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a potential race condition when aborting a session followed immediately by reverting. If the abort operation fails or takes time, the revert might execute on a session that's still running. While the catch block prevents the error from propagating, consider whether the revert should only proceed after confirming the abort succeeded, or handle the case where the session might still be processing.

Copilot uses AI. Check for mistakes.

// Restore prompt from the target message
const parts = sync.data.part[targetID]
if (parts) {
const restored = extractPromptFromParts(parts, { directory: sdk.directory })
prompt.set(restored)
}

// Update active message to the one we reverted to or the one before
setActiveMessage(idx < msgs.length - 1 ? msgs[idx] : msgs[idx - 1])
Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When reverting the first and only message in a session (when idx = 0 and msgs.length = 1), the condition idx < msgs.length - 1 evaluates to false, so targetID = messageID (the first message). After reversion, setActiveMessage(msgs[idx - 1]) evaluates to msgs[-1] which is undefined. This edge case should be handled to prevent setting an undefined active message.

Suggested change
setActiveMessage(idx < msgs.length - 1 ? msgs[idx] : msgs[idx - 1])
const newActiveMessage =
idx < msgs.length - 1
? msgs[idx] // there is a next message; keep this one active
: idx > 0
? msgs[idx - 1] // no next message; go to previous
: msgs[0] // single-message case; keep the only message
setActiveMessage(newActiveMessage)

Copilot uses AI. Check for mistakes.
}}
Comment on lines +1180 to +1206
Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The logic in this handler is complex and duplicates similar logic from the existing session.undo command (lines 566-593). Consider extracting this revert logic into a shared helper function to reduce code duplication and improve maintainability. Both implementations should be kept in sync, and a shared function would prevent divergence.

Copilot uses AI. Check for mistakes.
classes={{
root: "min-w-0 w-full relative",
content:
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/components/icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const icons = {
"align-right": `<path d="M12.292 6.04167L16.2503 9.99998L12.292 13.9583M2.91699 9.99998H15.6253M17.0837 3.75V16.25" stroke="currentColor" stroke-linecap="square"/>`,
"arrow-up": `<path fill-rule="evenodd" clip-rule="evenodd" d="M9.99991 2.24121L16.0921 8.33343L15.2083 9.21731L10.6249 4.63397V17.5001H9.37492V4.63398L4.7916 9.21731L3.90771 8.33343L9.99991 2.24121Z" fill="currentColor"/>`,
"arrow-left": `<path d="M8.33464 4.58398L2.91797 10.0007L8.33464 15.4173M3.33464 10.0007H17.0846" stroke="currentColor" stroke-linecap="square"/>`,
undo: `<path d="M4.16667 7.5H12.0833C14.3846 7.5 16.25 9.36548 16.25 11.6667C16.25 13.9679 14.3846 15.8333 12.0833 15.8333H7.5M4.16667 7.5L7.5 4.16667M4.16667 7.5L7.5 10.8333" stroke="currentColor" stroke-linecap="square"/>`,
archive: `<path d="M16.8747 6.24935H17.3747V5.74935H16.8747V6.24935ZM16.8747 16.8743V17.3743H17.3747V16.8743H16.8747ZM3.12467 16.8743H2.62467V17.3743H3.12467V16.8743ZM3.12467 6.24935V5.74935H2.62467V6.24935H3.12467ZM2.08301 2.91602V2.41602H1.58301V2.91602H2.08301ZM17.9163 2.91602H18.4163V2.41602H17.9163V2.91602ZM17.9163 6.24935V6.74935H18.4163V6.24935H17.9163ZM2.08301 6.24935H1.58301V6.74935H2.08301V6.24935ZM8.33301 9.08268H7.83301V10.0827H8.33301V9.58268V9.08268ZM11.6663 10.0827H12.1663V9.08268H11.6663V9.58268V10.0827ZM16.8747 6.24935H16.3747V16.8743H16.8747H17.3747V6.24935H16.8747ZM16.8747 16.8743V16.3743H3.12467V16.8743V17.3743H16.8747V16.8743ZM3.12467 16.8743H3.62467V6.24935H3.12467H2.62467V16.8743H3.12467ZM3.12467 6.24935V6.74935H16.8747V6.24935V5.74935H3.12467V6.24935ZM2.08301 2.91602V3.41602H17.9163V2.91602V2.41602H2.08301V2.91602ZM17.9163 2.91602H17.4163V6.24935H17.9163H18.4163V2.91602H17.9163ZM17.9163 6.24935V5.74935H2.08301V6.24935V6.74935H17.9163V6.24935ZM2.08301 6.24935H2.58301V2.91602H2.08301H1.58301V6.24935H2.08301ZM8.33301 9.58268V10.0827H11.6663V9.58268V9.08268H8.33301V9.58268Z" fill="currentColor"/>`,
"bubble-5": `<path d="M18.3327 9.99935C18.3327 5.57227 15.0919 2.91602 9.99935 2.91602C4.90676 2.91602 1.66602 5.57227 1.66602 9.99935C1.66602 11.1487 2.45505 13.1006 2.57637 13.3939C2.58707 13.4197 2.59766 13.4434 2.60729 13.4697C2.69121 13.6987 3.04209 14.9354 1.66602 16.7674C3.51787 17.6528 5.48453 16.1973 5.48453 16.1973C6.84518 16.9193 8.46417 17.0827 9.99935 17.0827C15.0919 17.0827 18.3327 14.4264 18.3327 9.99935Z" stroke="currentColor" stroke-linecap="square"/>`,
brain: `<path d="M13.332 8.7487C11.4911 8.7487 9.9987 7.25631 9.9987 5.41536M6.66536 11.2487C8.50631 11.2487 9.9987 12.7411 9.9987 14.582M9.9987 2.78209L9.9987 17.0658M16.004 15.0475C17.1255 14.5876 17.9154 13.4849 17.9154 12.1978C17.9154 11.3363 17.5615 10.5575 16.9913 9.9987C17.5615 9.43991 17.9154 8.66108 17.9154 7.79962C17.9154 6.21199 16.7136 4.90504 15.1702 4.73878C14.7858 3.21216 13.4039 2.08203 11.758 2.08203C11.1171 2.08203 10.5162 2.25337 9.9987 2.55275C9.48117 2.25337 8.88032 2.08203 8.23944 2.08203C6.59353 2.08203 5.21157 3.21216 4.82722 4.73878C3.28377 4.90504 2.08203 6.21199 2.08203 7.79962C2.08203 8.66108 2.43585 9.43991 3.00609 9.9987C2.43585 10.5575 2.08203 11.3363 2.08203 12.1978C2.08203 13.4849 2.87191 14.5876 3.99339 15.0475C4.46688 16.7033 5.9917 17.9154 7.79962 17.9154C8.61335 17.9154 9.36972 17.6698 9.9987 17.2488C10.6277 17.6698 11.384 17.9154 12.1978 17.9154C14.0057 17.9154 15.5305 16.7033 16.004 15.0475Z" stroke="currentColor"/>`,
Expand Down
40 changes: 39 additions & 1 deletion packages/ui/src/components/session-turn.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ import { createStore } from "solid-js/store"
import { DateTime, DurationUnit, Interval } from "luxon"
import { createAutoScroll } from "../hooks"

import { DropdownMenu } from "./dropdown-menu"

function computeStatusFromPart(part: PartType | undefined): string | undefined {
if (!part) return undefined

Expand Down Expand Up @@ -124,6 +126,7 @@ export function SessionTurn(
stepsExpanded?: boolean
onStepsExpandedToggle?: () => void
onUserInteracted?: () => void
Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new onRevert prop lacks JSDoc documentation. Adding documentation would help other developers understand when and how this callback is invoked, what the messageID parameter represents, and the expected behavior.

Suggested change
onUserInteracted?: () => void
onUserInteracted?: () => void
/**
* Optional callback invoked when the user chooses to revert a message in this session.
*
* @param messageID - The identifier of the message to revert within the current session.
*/

Copilot uses AI. Check for mistakes.
onRevert?: (messageID: string) => void
classes?: {
root?: string
content?: string
Expand Down Expand Up @@ -514,7 +517,42 @@ export function SessionTurn(
</div>
{/* User Message */}
<div data-slot="session-turn-message-content">
<Message message={msg()} parts={parts()} />
<DropdownMenu placement="bottom-start" gutter={0}>
<DropdownMenu.Trigger
as={(p: any) => (
Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using any type for the render prop parameter weakens type safety. Consider defining a proper type for the trigger props to maintain type safety and better IDE support.

Copilot uses AI. Check for mistakes.
<div
{...p}
onContextMenu={(e) => {
e.preventDefault()
p.onClick(e)
}}
>
<Message message={msg()} parts={parts()} />
</div>
)}
/>
Comment on lines +520 to +533
Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The context menu is only triggered by right-click (onContextMenu), which may not be accessible to keyboard-only users. While the DropdownMenu component likely provides keyboard support through the underlying Kobalte library, users should be able to access this functionality without a mouse. Consider documenting the keyboard shortcut or ensuring there's an alternative way to access these actions via keyboard.

Copilot uses AI. Check for mistakes.
<DropdownMenu.Portal>
<DropdownMenu.Content>
<Show when={props.onRevert}>
<DropdownMenu.Item onSelect={() => props.onRevert?.(msg().id)}>
<Icon name="undo" size="small" />
<DropdownMenu.ItemLabel>Undo from here</DropdownMenu.ItemLabel>
Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The label "Undo from here" is inconsistent with the PR description and may be confusing. According to the description, this should be "Revert to here" for older messages or act as "Undo" for the latest message. The label should dynamically reflect this distinction to match user expectations, or use a consistent label like "Revert to here" as mentioned in the PR title and description.

Suggested change
<DropdownMenu.ItemLabel>Undo from here</DropdownMenu.ItemLabel>
<DropdownMenu.ItemLabel>Revert to here</DropdownMenu.ItemLabel>

Copilot uses AI. Check for mistakes.
</DropdownMenu.Item>
</Show>
<DropdownMenu.Item
onSelect={() => {
const text = parts()
.map((p) => (p?.type === "text" ? (p as TextPart).text : ""))
.join("")
navigator.clipboard.writeText(text)
Comment on lines +543 to +547
Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The clipboard write operation lacks error handling. If the clipboard API fails (e.g., due to permissions or browser restrictions), the user won't receive any feedback. Consider adding error handling with a try-catch block and potentially showing a toast notification on success or failure.

Suggested change
onSelect={() => {
const text = parts()
.map((p) => (p?.type === "text" ? (p as TextPart).text : ""))
.join("")
navigator.clipboard.writeText(text)
onSelect={async () => {
const text = parts()
.map((p) => (p?.type === "text" ? (p as TextPart).text : ""))
.join("")
try {
await navigator.clipboard.writeText(text)
window.alert("Message copied to clipboard.")
} catch (error) {
console.error("Failed to copy message to clipboard:", error)
window.alert("Failed to copy message to clipboard. Please try again.")
}

Copilot uses AI. Check for mistakes.
}}
>
<Icon name="copy" size="small" />
<DropdownMenu.ItemLabel>Copy message</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
Comment on lines +520 to +555
Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The context menu wraps the entire Message component, which could cause unexpected behavior if the Message component itself has interactive elements or handles click events. This could lead to event conflicts or prevent users from interacting with the message content normally. Consider adding a visual indicator (like a small icon/button) that the message has a context menu, or ensure that the Message component's interactive elements aren't affected by this wrapper.

Suggested change
<DropdownMenu placement="bottom-start" gutter={0}>
<DropdownMenu.Trigger
as={(p: any) => (
<div
{...p}
onContextMenu={(e) => {
e.preventDefault()
p.onClick(e)
}}
>
<Message message={msg()} parts={parts()} />
</div>
)}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content>
<Show when={props.onRevert}>
<DropdownMenu.Item onSelect={() => props.onRevert?.(msg().id)}>
<Icon name="undo" size="small" />
<DropdownMenu.ItemLabel>Undo from here</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Item
onSelect={() => {
const text = parts()
.map((p) => (p?.type === "text" ? (p as TextPart).text : ""))
.join("")
navigator.clipboard.writeText(text)
}}
>
<Icon name="copy" size="small" />
<DropdownMenu.ItemLabel>Copy message</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
<div data-slot="session-turn-message-with-menu">
<Message message={msg()} parts={parts()} />
<DropdownMenu placement="bottom-start" gutter={0}>
<DropdownMenu.Trigger
as={(p: any) => (
<button
type="button"
{...p}
data-slot="session-turn-message-menu-trigger"
onClick={(e) => {
e.stopPropagation()
p.onClick?.(e)
}}
onContextMenu={(e) => {
e.preventDefault()
e.stopPropagation()
p.onClick?.(e)
}}
>
<Icon name="chevron-grabber-vertical" size="small" />
</button>
)}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content>
<Show when={props.onRevert}>
<DropdownMenu.Item onSelect={() => props.onRevert?.(msg().id)}>
<Icon name="undo" size="small" />
<DropdownMenu.ItemLabel>Undo from here</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Item
onSelect={() => {
const text = parts()
.map((p) => (p?.type === "text" ? (p as TextPart).text : ""))
.join("")
navigator.clipboard.writeText(text)
}}
>
<Icon name="copy" size="small" />
<DropdownMenu.ItemLabel>Copy message</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</div>

Copilot uses AI. Check for mistakes.
</div>
{/* Trigger (sticky) */}
<Show when={working() || hasSteps()}>
Expand Down