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
44 changes: 19 additions & 25 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,11 @@ import {
connectionStatusTitle,
type EnvironmentConnectionPresentation,
} from "@t3tools/client-runtime/connection";
import { effectiveSettled, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled";
import {
effectiveSettled,
effectiveSnoozed,
threadWokeAt,
} from "@t3tools/client-runtime/state/thread-settled";
import {
parseScopedThreadKey,
scopedThreadKey,
Expand Down Expand Up @@ -1232,9 +1236,6 @@ function ChatViewContent(props: ChatViewProps) {
);
const activeServerThread = serverThread ?? loadingServerThread;
const markThreadVisited = useUiStateStore((store) => store.markThreadVisited);
const activeThreadLastVisitedAt = useUiStateStore(
(store) => store.threadLastVisitedAtById[routeThreadKey],
);
const settings = useEnvironmentSettings(environmentId);
// New-thread defaults live in the primary environment's settings.json (the
// settings UI never writes to remote environments), so read them from the
Expand Down Expand Up @@ -1851,25 +1852,6 @@ function ChatViewContent(props: ChatViewProps) {
[openOrReuseProjectDraftThread],
);

useEffect(() => {
if (!serverThread?.id) return;
const threadUpdatedAt = Date.parse(serverThread.updatedAt);
if (Number.isNaN(threadUpdatedAt)) return;
const lastVisitedAt = activeThreadLastVisitedAt ? Date.parse(activeThreadLastVisitedAt) : NaN;
if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= threadUpdatedAt) return;

markThreadVisited(
scopedThreadKey(scopeThreadRef(serverThread.environmentId, serverThread.id)),
serverThread.updatedAt,
);
}, [
activeThreadLastVisitedAt,
markThreadVisited,
serverThread?.environmentId,
serverThread?.id,
serverThread?.updatedAt,
]);

const selectedProviderByThreadId = composerActiveProvider ?? null;
const threadProvider =
activeThread?.modelSelection.instanceId ??
Expand Down Expand Up @@ -3997,13 +3979,18 @@ function ChatViewContent(props: ChatViewProps) {
const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true;
const supportsSnooze = serverConfig?.environment.capabilities.threadSnooze === true;
const nowMinute = useNowMinute();
const snoozeNow = new Date().toISOString();
const activeThreadSnoozed =
activeThreadShell !== null &&
supportsSnooze &&
effectiveSnoozed(activeThreadShell, { now: new Date().toISOString() });
effectiveSnoozed(activeThreadShell, { now: snoozeNow });
const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0);
void snoozeWakeTick;
const activeThreadWokeAt =
activeThreadShell !== null && supportsSnooze
? threadWokeAt(activeThreadShell, { now: snoozeNow })
: null;
useEffect(() => {
void snoozeWakeTick;
if (!activeThreadSnoozed) return;
const wakeAtMs = Date.parse(activeThreadShell?.snoozedUntil ?? "");
if (!Number.isFinite(wakeAtMs)) return;
Expand All @@ -4013,6 +4000,10 @@ function ChatViewContent(props: ChatViewProps) {
);
return () => window.clearTimeout(id);
}, [activeThreadShell?.snoozedUntil, activeThreadSnoozed, snoozeWakeTick]);
const acknowledgeActiveThreadWoke = useCallback(() => {
if (activeThreadRef === null || activeThreadWokeAt === null) return;
markThreadVisited(scopedThreadKey(activeThreadRef), activeThreadWokeAt);
}, [activeThreadRef, activeThreadWokeAt, markThreadVisited]);
Comment thread
StiensWout marked this conversation as resolved.
const activeThreadSettled = useMemo(() => {
if (activeThreadShell === null || !supportsSettlement) return false;
return effectiveSettled(activeThreadShell, {
Expand Down Expand Up @@ -5054,6 +5045,7 @@ function ChatViewContent(props: ChatViewProps) {
failure = startResult;
} else {
turnStartSucceeded = true;
acknowledgeActiveThreadWoke();
}
}

Expand Down Expand Up @@ -5413,6 +5405,7 @@ function ChatViewContent(props: ChatViewProps) {
}

if (failure === null) {
acknowledgeActiveThreadWoke();
// Optimistically open the plan sidebar when implementing (not refining).
// "default" mode here means the agent is executing the plan, which produces
// step-tracking activities that the sidebar will display.
Expand Down Expand Up @@ -5442,6 +5435,7 @@ function ChatViewContent(props: ChatViewProps) {
[
activeThread,
activeProposedPlan,
acknowledgeActiveThreadWoke,
beginLocalDispatch,
isConnecting,
isSendBusy,
Expand Down
131 changes: 92 additions & 39 deletions apps/web/src/components/SidebarV2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
onUnsettle: (threadRef: ScopedThreadRef) => void;
onSnooze: (threadRef: ScopedThreadRef, preset: SnoozePreset) => void;
onUnsnooze: (threadRef: ScopedThreadRef) => void;
onAcknowledgeWoke: (threadRef: ScopedThreadRef, visitedAt: string) => void;
onChangeRequestState: (threadKey: string, state: "open" | "closed" | "merged" | null) => void;
}) {
const {
Expand All @@ -439,6 +440,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
onCancelRename,
onCommitRename,
onContextMenu,
onAcknowledgeWoke,
onRenameTitleChange,
onSettle,
onSnooze,
Expand Down Expand Up @@ -542,6 +544,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
className: "text-emerald-700 dark:text-emerald-300",
}
: null;
const isWokeStatus = topStatus?.icon === "woke";

const gitCwd = thread.worktreePath ?? props.projectCwd;
const gitStatus = useEnvironmentQuery(
Expand Down Expand Up @@ -605,6 +608,15 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
},
[onThreadClick, threadRef],
);
const handleAcknowledgeWokeClick = useCallback(
(event: ReactMouseEvent) => {
event.preventDefault();
event.stopPropagation();
if (props.wokeAt === null) return;
onAcknowledgeWoke(threadRef, props.wokeAt);
},
[onAcknowledgeWoke, props.wokeAt, threadRef],
);
const handleContextMenu = useCallback(
(event: ReactMouseEvent) => {
event.preventDefault();
Expand Down Expand Up @@ -853,7 +865,12 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
the time/jump label yields to the settle affordance. */}
{prBadge}
<span className="relative ml-auto flex h-6 min-w-8 shrink-0 items-center justify-end">
<span className="inline-flex justify-end tabular-nums text-muted-foreground/55 transition-opacity group-hover/v2-row:opacity-0">
<span
className={cn(
"inline-flex justify-end tabular-nums text-muted-foreground/55 transition-opacity",
!isWoke && "group-hover/v2-row:opacity-0",
)}
>
{variantAction === "unsnooze" && props.snoozeWakeLabelText !== null ? (
// Snoozed rows show when they come BACK, not when they were
// last touched — the return ticket is the row's whole story.
Expand All @@ -863,14 +880,16 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
) : isWoke ? (
// A wake can land straight in the settled tail (e.g. PR
// merged while snoozed); the signal must survive the trip.
<span
role="status"
aria-label="Woke from snooze"
className="inline-flex items-center gap-1 text-xs font-medium text-amber-700 dark:text-amber-300"
<button
type="button"
aria-label="Dismiss Woke notification"
title="Dismiss Woke notification"
onClick={handleAcknowledgeWokeClick}
className="inline-flex cursor-pointer items-center gap-1 rounded-sm text-xs font-medium text-amber-700 outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring dark:text-amber-300"
>
<AlarmClockIcon aria-hidden className="size-3" />
Woke
</span>
<span role="status">Woke</span>
</button>
Comment thread
cursor[bot] marked this conversation as resolved.
) : (
<span className="text-xs">
{variantAction === "unsettle"
Expand All @@ -885,7 +904,10 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
type="button"
aria-label="Wake thread now"
onClick={handleUnsnoozeClick}
className="absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/v2-row:opacity-100"
className={cn(
"pointer-events-none absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/v2-row:pointer-events-auto group-hover/v2-row:opacity-100",
isWoke && "group-hover/v2-row:static",
)}
>
<AlarmClockOffIcon className="size-3" />
</button>
Expand All @@ -895,7 +917,10 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
type="button"
aria-label="Un-settle thread"
onClick={handleUnsettleClick}
className="absolute inset-y-0 right-0 -mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/v2-row:opacity-100"
className={cn(
"pointer-events-none absolute inset-y-0 right-0 -mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/v2-row:pointer-events-auto group-hover/v2-row:opacity-100",
isWoke && "group-hover/v2-row:static",
)}
>
<Undo2Icon className="mb-px size-3.5" />
</button>
Expand All @@ -904,7 +929,10 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
type="button"
aria-label="Settle thread"
onClick={handleSettleClick}
className="absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/v2-row:opacity-100"
className={cn(
"pointer-events-none absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/v2-row:pointer-events-auto group-hover/v2-row:opacity-100",
isWoke && "group-hover/v2-row:static",
)}
>
<CheckIcon className="size-3" />
</button>
Expand Down Expand Up @@ -972,39 +1000,56 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
the hidden state out of flow lets the project label reclaim
space without either state overlapping it. */}
<span className="group/v2-status-slot relative ml-auto flex h-5 min-w-8 shrink-0 items-stretch justify-end text-xs">
{/* pointer-events-none: while hovered this label is absolute
+ opacity-0, which paints it ABOVE the in-flow settle/snooze
buttons; without it the invisible label eats their clicks. */}
{/* Read-only status labels yield to the hover actions. Woke is
itself an action, so it stays pointer-enabled and visible
while the other controls appear beside it. */}
<span
className={cn(
"pointer-events-none self-center justify-self-end tabular-nums text-muted-foreground/65 transition-opacity group-has-[:focus-visible]/v2-status-slot:absolute group-has-[:focus-visible]/v2-status-slot:right-0 group-has-[:focus-visible]/v2-status-slot:opacity-0 group-hover/v2-row:absolute group-hover/v2-row:right-0 group-hover/v2-row:opacity-0",
snoozeMenuOpen && "absolute right-0 opacity-0",
isWokeStatus
? "pointer-events-auto"
: "pointer-events-none group-has-[:focus-visible]/v2-status-slot:absolute group-has-[:focus-visible]/v2-status-slot:right-0 group-has-[:focus-visible]/v2-status-slot:opacity-0 group-hover/v2-row:absolute group-hover/v2-row:right-0 group-hover/v2-row:opacity-0",
"self-center justify-self-end tabular-nums text-muted-foreground/65 transition-opacity",
snoozeMenuOpen && "pointer-events-none absolute right-0 opacity-0",
)}
>
{topStatus ? (
<span
className={cn(
"inline-flex items-center gap-1 font-medium",
topStatus.className,
)}
>
{topStatus.icon === "working" ? (
<CircleDashedIcon aria-hidden className="size-4 shrink-0" />
) : topStatus.icon === "done" ? (
<CircleCheckIcon aria-hidden className="size-4 shrink-0" />
) : topStatus.icon === "woke" ? (
isWokeStatus ? (
<button
type="button"
aria-label="Dismiss Woke notification"
title="Dismiss Woke notification"
onClick={handleAcknowledgeWokeClick}
className={cn(
"inline-flex cursor-pointer items-center gap-1 rounded-sm font-medium outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring",
topStatus.className,
)}
>
<AlarmClockIcon aria-hidden className="size-4 shrink-0" />
) : null}
{/* The label alone is the live region: a role="status"
wrapper around the ticking duration would make
screen readers announce every second. */}
<span role="status">{topStatus.label}</span>
{status === "working" ? (
<span aria-hidden>
<WorkingDuration startedAt={resolveWorkingStartedAt(thread)} />
</span>
) : null}
</span>
<span role="status">{topStatus.label}</span>
</button>
) : (
<span
className={cn(
"inline-flex items-center gap-1 font-medium",
topStatus.className,
)}
>
{topStatus.icon === "working" ? (
<CircleDashedIcon aria-hidden className="size-4 shrink-0" />
) : topStatus.icon === "done" ? (
<CircleCheckIcon aria-hidden className="size-4 shrink-0" />
) : null}
{/* The label alone is the live region: a role="status"
wrapper around the ticking duration would make
screen readers announce every second. */}
<span role="status">{topStatus.label}</span>
{status === "working" ? (
<span aria-hidden>
<WorkingDuration startedAt={resolveWorkingStartedAt(thread)} />
</span>
) : null}
</span>
)
Comment thread
cursor[bot] marked this conversation as resolved.
) : (
threadTimeLabel(thread)
)}
Expand All @@ -1017,8 +1062,8 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
// would keep the controls pinned over the status label
// once the pointer moves away (e.g. after a failed
// settle) instead of cross-fading back.
"absolute inset-y-0 right-0 flex items-stretch opacity-0 transition-opacity has-[:focus-visible]:static has-[:focus-visible]:opacity-100 group-hover/v2-row:static group-hover/v2-row:opacity-100",
snoozeMenuOpen && "static opacity-100",
"pointer-events-none absolute inset-y-0 right-0 flex items-stretch opacity-0 transition-opacity has-[:focus-visible]:pointer-events-auto has-[:focus-visible]:static has-[:focus-visible]:opacity-100 group-hover/v2-row:pointer-events-auto group-hover/v2-row:static group-hover/v2-row:opacity-100",
snoozeMenuOpen && "pointer-events-auto static opacity-100",
)}
>
{showSnoozeButton ? (
Expand Down Expand Up @@ -1288,6 +1333,13 @@ export default function SidebarV2() {
const toggleThreadSelection = useThreadSelectionStore((s) => s.toggleThread);
const rangeSelectTo = useThreadSelectionStore((s) => s.rangeSelectTo);
const markThreadUnread = useUiStateStore((s) => s.markThreadUnread);
const markThreadVisited = useUiStateStore((s) => s.markThreadVisited);
const acknowledgeWoke = useCallback(
(threadRef: ScopedThreadRef, visitedAt: string) => {
markThreadVisited(scopedThreadKey(threadRef), visitedAt);
},
[markThreadVisited],
);
const routeTarget = useParams({
strict: false,
select: (params) => resolveThreadRouteTarget(params),
Expand Down Expand Up @@ -3022,6 +3074,7 @@ export default function SidebarV2() {
onUnsettle={attemptUnsettle}
onSnooze={attemptSnooze}
onUnsnooze={attemptUnsnooze}
onAcknowledgeWoke={acknowledgeWoke}
onChangeRequestState={handleChangeRequestState}
/>
);
Expand Down
Loading
Loading