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
1 change: 1 addition & 0 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3314,6 +3314,7 @@ export function ChatComposer(props: ChatComposerProps) {
<ComposerApprovalPanel
key={activePendingApproval.requestId}
approval={activePendingApproval}
workspaceRoot={gitCwd ?? undefined}
count={pendingApprovals.length}
disabled={!canOperateThread}
isResponding={respondingRequestIds.includes(activePendingApproval.requestId)}
Expand Down
25 changes: 20 additions & 5 deletions apps/web/src/components/chat/ComposerApprovalPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { cn } from "../../lib/utils";
import { formatWorkspaceRelativeDetail, formatWorkspaceRelativePath } from "../../filePathDisplay";
import { ShieldIcon } from "lucide-react";
import { use, useEffect, useEffectEvent, useState, useSyncExternalStore } from "react";
import { BackgroundThreadContext } from "./backgroundThread";
Expand Down Expand Up @@ -37,19 +39,25 @@ const approvalKeysAvailable = () => !keepsApprovalKeys(document.activeElement);
export function ComposerApprovalPanel({
approval,
count,
workspaceRoot,
disabled,
isResponding,
onRespond,
}: {
approval: PendingApproval;
count: number;
workspaceRoot?: string | undefined;
disabled: boolean;
isResponding: boolean;
onRespond: (requestId: RuntimeRequestId, decision: ProviderApprovalDecision) => Promise<unknown>;
}) {
const backgroundThread = use(BackgroundThreadContext);
const paneFocusedRef = useLayoutPaneFocusedRef();
const showShortcutHints = useSyncExternalStore(subscribeApprovalFocus, approvalKeysAvailable);
const detail =
approval.requestKind === "file-change"
? formatWorkspaceRelativeDetail(approval.detail, workspaceRoot)
: approval.detail;
const [expanded, setExpanded] = useState(true);
const [alwaysAllow, setAlwaysAllow] = useState(false);
const canRespond =
Expand Down Expand Up @@ -100,7 +108,7 @@ export function ComposerApprovalPanel({
render={<CollapsibleTrigger render={<button type="button" />} />}
icon={<ShieldIcon />}
title={title}
sub={expanded ? null : approval.detail}
sub={expanded ? null : detail}
trailing={
<>
{count > 1 ? <ComposerRequestCard.Counter index={1} total={count} /> : null}
Expand Down Expand Up @@ -129,12 +137,19 @@ export function ComposerApprovalPanel({
/>
<CollapsiblePanel>
<ComposerRequestCard.Well>
<pre className="max-h-40 overflow-auto whitespace-pre-wrap p-2 font-mono text-meta text-foreground wrap-anywhere">
{approval.detail}
</pre>
<div
className={cn(
"max-h-40 overflow-auto whitespace-pre-wrap p-2 text-meta text-muted-foreground wrap-anywhere",
approval.requestKind === "command" && "font-mono text-foreground",
)}
>
{detail}
</div>
{approval.files?.map((file) => (
<div key={file.path} className="flex min-w-0 items-center gap-2 px-2 pb-1 text-meta">
<span className="truncate">{file.path}</span>
<span className="truncate text-muted-foreground">
{formatWorkspaceRelativePath(file.path, workspaceRoot)}
</span>
<span className="text-success-foreground">
{file.additions !== undefined ? `+${file.additions}` : null}
</span>
Expand Down
27 changes: 23 additions & 4 deletions apps/web/src/components/chat/MessagesTimeline.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -529,6 +529,7 @@ const LIVE_ACTIVITY_ROW_ID = "live-activity-row";

export type MessagesTimelineRow = MessagesTimelineRowContent & {
readonly continuesWorkLog?: boolean;
readonly superseded?: boolean;
/** A user prompt followed directly by another prompt sits in the same prompt stack. */
readonly continuesPromptStack?: boolean;
};
Expand Down Expand Up @@ -2624,15 +2625,32 @@ export function deriveMessagesTimelineRows(input: {
}
}

const supersededRowIds = new Set<string>();
for (const fold of supersededFoldsByAnchorEntryId.values()) {
if (!input.expandedAttemptIds?.has(fold.attemptId)) continue;
for (const entryId of fold.hiddenEntryIds) {
supersededRowIds.add(entryId);
supersededRowIds.add(`work-toggle:${entryId}`);
supersededRowIds.add(`work-live:${entryId}`);
supersededRowIds.add(`${workGroupId(entryId)}:details`);
const entry = timelineEntryById.get(entryId);
if (entry?.kind === "message") {
supersededRowIds.add(`assistant-meta:${entry.message.id}`);
}
}
}
const result = attachTrailingToolGroupsToAssistant(
attachCreatedThreadSummaries(nextRows, timelineEntries, collapsedFoldMemberIds),
);
return withPromptGrouping(
result.map((row, index) =>
timelineRowIsWorkLog(row) && timelineRowIsWorkLog(result[index + 1])
result.map((sourceRow, index) => {
const row = supersededRowIds.has(sourceRow.id)
? { ...sourceRow, superseded: true }
: sourceRow;
return timelineRowIsWorkLog(row) && timelineRowIsWorkLog(result[index + 1])
? { ...row, continuesWorkLog: true }
: row,
),
: row;
}),
);
}

Expand Down Expand Up @@ -3486,6 +3504,7 @@ function withPreviousWindowItems(
function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean {
if (a.kind !== b.kind || a.id !== b.id) return false;
if (a.continuesPromptStack !== b.continuesPromptStack) return false;
if (a.superseded !== b.superseded) return false;

switch (a.kind) {
case "background-activity": {
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1644,6 +1644,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
return (
<div
className={cn(
row.superseded && "ms-6",
isWorkLogRow || isSubagentGroup
? undefined
: row.kind === "background-activity" && row.precedesPrompt
Expand Down Expand Up @@ -1678,6 +1679,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
wakeUpsRail === "body" && "before:bottom-0",
wakeUpsRail === "end" && "before:bottom-3",
)}
data-superseded-content={row.superseded || undefined}
data-timeline-row-id={row.id}
data-timeline-row-kind={row.kind}
data-message-id={
Expand Down
30 changes: 21 additions & 9 deletions apps/web/src/components/chat/TimelinePendingApprovalRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
import { FileDiffIcon, FileMinusIcon, FilePlusIcon, ShieldIcon } from "lucide-react";
import { memo, use, useState } from "react";
import { type PendingApproval } from "../../session-logic";
import { formatWorkspaceRelativeDetail, formatWorkspaceRelativePath } from "../../filePathDisplay";
import { cn } from "../../lib/utils";
import { InlineButton } from "../ui/button";
import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
Expand All @@ -26,7 +27,9 @@ function ApprovalFileDiff({ file }: { file: NonNullable<PendingApproval["files"]
const diff = file.diff || (item?.type === "file_change" ? item.diffStr : undefined);
return (
<div>
<div className="text-dim-foreground">{file.path}</div>
<div className="text-dim-foreground">
{formatWorkspaceRelativePath(file.path, ctx.workspaceRoot)}
</div>
{diff ? (
<pre>
<ApprovalDiffLines diff={diff} />
Expand Down Expand Up @@ -94,8 +97,12 @@ export const TimelinePendingApprovalRow = memo(function TimelinePendingApprovalR
}) {
const [diffRequestId, setDiffRequestId] = useState<PendingApproval["requestId"] | null>(null);
const diffOpen = diffRequestId === approval.requestId;
const { onRespondToApproval } = use(TimelineRowCtx);
const { onRespondToApproval, workspaceRoot } = use(TimelineRowCtx);
const presentation = APPROVAL_PRESENTATION[approval.requestKind];
const detail =
approval.requestKind === "file-change"
? formatWorkspaceRelativeDetail(approval.detail, workspaceRoot)
: approval.detail;
const canRespond = approval.responseCapability === "live";
const files = approval.requestKind === "file-change" ? (approval.files ?? []) : [];
const diffFiles = files.filter((file, index) =>
Expand All @@ -119,14 +126,17 @@ export const TimelinePendingApprovalRow = memo(function TimelinePendingApprovalR
/>
}
label={
<code
<div
aria-label={presentation.detailLabel}
className="block max-h-40 min-w-0 overflow-auto whitespace-pre-wrap font-mono text-meta leading-5 text-foreground select-text! wrap-anywhere [scrollbar-width:thin] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
className={cn(
"block max-h-40 min-w-0 overflow-auto whitespace-pre-wrap text-meta leading-5 text-muted-foreground select-text! wrap-anywhere [scrollbar-width:thin] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70",
approval.requestKind === "command" && "font-mono text-foreground",
)}
data-approval-detail="complete"
tabIndex={0}
>
{approval.detail || presentation.label}
</code>
{detail || presentation.label}
</div>
}
>
<div className="ms-7.5 flex min-w-0 flex-col gap-1 pb-1 text-meta">
Expand Down Expand Up @@ -158,13 +168,15 @@ export const TimelinePendingApprovalRow = memo(function TimelinePendingApprovalR
<FileDiffIcon aria-hidden className="size-[13px] shrink-0 text-info-foreground" />
)}
<Tooltip>
<TooltipTrigger render={<span className="min-w-0 truncate" />}>
{file.path}
<TooltipTrigger
render={<span className="min-w-0 truncate text-muted-foreground" />}
>
{formatWorkspaceRelativePath(file.path, workspaceRoot)}
</TooltipTrigger>
<TooltipPopup>{file.path}</TooltipPopup>
</Tooltip>
{file.additions !== undefined || file.deletions !== undefined ? (
<span className="shrink-0 font-mono text-caption tabular-nums">
<span className="shrink-0 text-meta tabular-nums">
<span className="text-success-foreground">+{file.additions ?? 0}</span>{" "}
<span className="text-error-foreground">−{file.deletions ?? 0}</span>
</span>
Expand Down
17 changes: 17 additions & 0 deletions apps/web/src/filePathDisplay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,23 @@ function stripRelativePrefixes(path: string): string {
return path.replace(/^\.\/+/, "").replace(/^\/+/, "");
}

export function formatWorkspaceRelativeDetail(
detail: string | undefined,
workspaceRoot: string | undefined,
): string | undefined {
if (!detail || !workspaceRoot) return detail;
const root = trimTrailingPathSeparators(workspaceRoot);
const label = fileBasename(normalizePathSeparators(root));
for (const prefix of new Set([root, normalizePathSeparators(root)])) {
const escaped = prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
detail = detail.replace(
new RegExp(`(^|[\\s"'\\x60(])${escaped}([/\\\\])`, "g"),
(_match, boundary: string, separator: string) => `${boundary}${label}${separator}`,
);
}
return detail;
}

export function formatWorkspaceRelativePath(
pathWithPosition: string,
workspaceRoot: string | undefined,
Expand Down
Loading