Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 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
31 changes: 31 additions & 0 deletions apps/mobile/src/features/threads/ComposerMergeBackNotice.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { pendingMergeBackNotice } from "@t3tools/client-runtime/state/thread-relationships";
import { View } from "react-native";

import { AppText as Text } from "../../components/AppText";
import { SymbolView } from "../../components/AppSymbol";

/**
* A merged-back fork changes what the next send carries. The notice leaves on
* its own once the next run consumes the transfer and the feed shows the
* context handoff divider.
*/
export function ComposerMergeBackNotice(props: {
readonly sourceThreadTitle: string | null;
readonly forkCount: number;
readonly waitsForIdle: boolean;
}) {
const notice = pendingMergeBackNotice(props);
return (
<View className="flex-row items-center gap-2 px-4 pb-2" accessibilityLiveRegion="polite">
<SymbolView
name={notice.blocked ? "exclamationmark.triangle" : "arrow.triangle.merge"}
size={12}
tintColorClassName="accent-foreground-muted"
/>
<Text className="min-w-0 flex-1 text-xs text-foreground-muted" numberOfLines={2}>
<Text className="font-t3-medium text-xs text-foreground">{notice.title}</Text>
{`. ${notice.description}`}
</Text>
</View>
);
}
10 changes: 10 additions & 0 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ import {
import { ThreadFeed, type ThreadFeedHistoryControls } from "./ThreadFeed";
import { useThreadTurnSubagents } from "./ThreadAgentsSheet";
import { ComposerQueuedEditBanner } from "./ComposerQueuedEdit";
import { ComposerMergeBackNotice } from "./ComposerMergeBackNotice";
import { useThreadQueuedCount } from "./ThreadQueueControl";
import type { ThreadContentPresentation } from "./threadContentPresentation";
import { resolveThreadFeedSubmissionAnchor } from "./thread-feed-live-follow";
Expand All @@ -143,6 +144,12 @@ export interface ThreadDetailScreenProps {
readonly environmentLabel: string | null;
readonly feedbackSubmissions: ReadonlyArray<CodexFeedbackSubmission>;
readonly onDismissFeedback: (id: MessageId) => void;
/** Set while a merged-back fork waits to be included in the next send. */
readonly pendingMergeBack: {
readonly sourceThreadTitle: string | null;
readonly forkCount: number;
readonly waitsForIdle: boolean;
} | null;
readonly selectedThreadFeed: ReadonlyArray<ThreadFeedEntry>;
readonly activityRun: ThreadFeedLatestRun | null;
readonly activeWorkStartedAt: string | null;
Expand Down Expand Up @@ -1172,6 +1179,9 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
/>
</Animated.View>
) : null}
{props.pendingMergeBack !== null ? (
<ComposerMergeBackNotice {...props.pendingMergeBack} />
) : null}
<UsageLimitRecoveryCard
key={props.selectedThread.latestRun?.runId}
thread={props.selectedThread}
Expand Down
41 changes: 40 additions & 1 deletion apps/mobile/src/features/threads/ThreadRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,12 @@ import { useSelectedThreadGitState } from "../../state/use-selected-thread-git-s
import { useSelectedThreadRequests } from "../../state/use-selected-thread-requests";
import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree";
import { useThreadComposerState } from "../../state/use-thread-composer-state";
import { resolveMergeBackTargetThreadId } from "@t3tools/client-runtime/state/thread-relationships";
import {
resolveMergeBackTargetThreadId,
resolvePendingMergeBack,
} from "@t3tools/client-runtime/state/thread-relationships";
import { scopeThreadRef } from "@t3tools/client-runtime/environment";
import { useThreadShell } from "../../state/entities";
import { resolveLatestMergeBackRun } from "@t3tools/client-runtime/state/thread-workflows";
import { threadEnvironment } from "../../state/threads";
import { projectThreadContentPresentation } from "./threadContentPresentation";
Expand Down Expand Up @@ -372,6 +377,39 @@ function ThreadRouteContent(
const mergeBackRun =
selectedThreadDetail === null ? null : resolveLatestMergeBackRun(selectedThreadDetail);
const mergeBackBusyRef = useRef(false);
const pendingMergeBack = useMemo(
() => resolvePendingMergeBack(selectedThreadDetail),
[selectedThreadDetail],
);
// Primitives keep the memoized detail screen stable while the projection streams.
const mergeBackSourceThreadId = pendingMergeBack?.transfer.sourceThreadId ?? null;
const mergeBackForkCount = pendingMergeBack?.forkCount ?? 0;
const mergeBackWaitsForIdle = pendingMergeBack?.waitsForIdle ?? false;
const selectedEnvironmentId = selectedThread?.environmentId ?? null;
const pendingMergeBackSourceRef = useMemo(
() =>
mergeBackSourceThreadId === null || selectedEnvironmentId === null
? null
: scopeThreadRef(selectedEnvironmentId, mergeBackSourceThreadId),
[mergeBackSourceThreadId, selectedEnvironmentId],
);
const pendingMergeBackSourceTitle = useThreadShell(pendingMergeBackSourceRef)?.title ?? null;
const pendingMergeBackNotice = useMemo(
() =>
mergeBackSourceThreadId === null
? null
: {
sourceThreadTitle: pendingMergeBackSourceTitle,
forkCount: mergeBackForkCount,
waitsForIdle: mergeBackWaitsForIdle,
},
[
mergeBackForkCount,
mergeBackSourceThreadId,
mergeBackWaitsForIdle,
pendingMergeBackSourceTitle,
],
);
const handleMergeBack = useCallback(async () => {
if (
mergeBackBusyRef.current ||
Expand Down Expand Up @@ -996,6 +1034,7 @@ function ThreadRouteContent(
environmentLabel={selectedEnvironmentConnection?.environmentLabel ?? null}
feedbackSubmissions={composer.feedbackSubmissions}
onDismissFeedback={composer.dismissFeedback}
pendingMergeBack={pendingMergeBackNotice}
selectedThreadFeed={composer.selectedThreadFeed}
activityRun={composer.selectedThreadActivityRun}
activeWorkStartedAt={
Expand Down
35 changes: 35 additions & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
isUsageLimitsCommand,
} from "@t3tools/shared/usageLimits";
import { feedbackBannerItem } from "./chat/ComposerFeedback";
import { mergeBackBannerItem } from "./chat/ComposerMergeBackNotice";
import { usageLimitsBannerItem } from "./chat/ComposerUsageLimits";
import { getTerminalLabel } from "@t3tools/shared/terminalLabels";
import * as Schema from "effect/Schema";
Expand Down Expand Up @@ -97,6 +98,7 @@ import {
presentPendingBackgroundWork,
} from "@t3tools/client-runtime/state/thread-execution";
import { threadSupportsProviderHandoff } from "@t3tools/client-runtime/state/thread-workflows";
import { resolvePendingMergeBack } from "@t3tools/client-runtime/state/thread-relationships";
import {
codexFeedbackMessage,
parseCodexFeedbackCommand,
Expand Down Expand Up @@ -6972,6 +6974,35 @@ export default function ChatView(props: ChatViewProps) {
onDismiss: acknowledgeActiveThreadWoke,
};
}, [acknowledgeActiveThreadWoke, activeThread?.id, activeThreadWokeVisible]);
const pendingMergeBack = useMemo(
() => resolvePendingMergeBack(serverProjection),
[serverProjection],
);
// Primitives keep the banner stable while the projection streams.
const mergeBackTransferId = pendingMergeBack?.transfer.id ?? null;
const mergeBackSourceThreadId = pendingMergeBack?.transfer.sourceThreadId ?? null;
const mergeBackForkCount = pendingMergeBack?.forkCount ?? 0;
const mergeBackWaitsForIdle = pendingMergeBack?.waitsForIdle ?? false;
const mergeBackSourceRef = useMemo(
() =>
mergeBackSourceThreadId === null
? null
: scopeThreadRef(environmentId, mergeBackSourceThreadId),
[environmentId, mergeBackSourceThreadId],
);
const mergeBackSourceTitle = useThreadShell(mergeBackSourceRef)?.title ?? null;
const mergeBackBanner = useMemo(
() =>
mergeBackTransferId === null
? null
: mergeBackBannerItem({
transferId: mergeBackTransferId,
sourceThreadTitle: mergeBackSourceTitle,
forkCount: mergeBackForkCount,
waitsForIdle: mergeBackWaitsForIdle,
}),
[mergeBackForkCount, mergeBackSourceTitle, mergeBackTransferId, mergeBackWaitsForIdle],
);
const parkedThreadBannerItem = useMemo<ComposerBannerStackItem | null>(() => {
if (!activeThreadSnoozed && !activeThreadSettled) {
return null;
Expand Down Expand Up @@ -7166,12 +7197,14 @@ export default function ChatView(props: ChatViewProps) {
const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem];
// The user asked for this one, so it leads the notice tier instead of trailing it.
const usageLimitsItems = usageLimitsBanner === null ? [] : [usageLimitsBanner];
const mergeBackItems = mergeBackBanner === null ? [] : [mergeBackBanner];
const projectCloneItems = projectCloneBannerItem === null ? [] : [projectCloneBannerItem];
if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) {
return [
...feedbackBannerItems,
...limitRecoveryItems,
...usageLimitsItems,
...mergeBackItems,
...projectCloneItems,
...systemComposerBannerItems,
...backgroundWorkItems,
Expand All @@ -7184,6 +7217,7 @@ export default function ChatView(props: ChatViewProps) {
...feedbackBannerItems,
...limitRecoveryItems,
...usageLimitsItems,
...mergeBackItems,
...projectCloneItems,
...systemComposerBannerItems,
...backgroundWorkItems,
Expand Down Expand Up @@ -7239,6 +7273,7 @@ export default function ChatView(props: ChatViewProps) {
isRestoringThreadBranch,
backgroundWorkBannerItem,
localCheckoutBranchMismatch,
mergeBackBanner,
parkedThreadBannerItem,
projectCloneBannerItem,
resumeCompactionBannerItem,
Expand Down
25 changes: 25 additions & 0 deletions apps/web/src/components/chat/ComposerMergeBackNotice.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { pendingMergeBackNotice } from "@t3tools/client-runtime/state/thread-relationships";
import type { ContextTransferId } from "@t3tools/contracts";

import { PullRequestGlyph } from "../pullRequest/pullRequestIcons";
import type { ComposerBannerStackItem } from "./ComposerBannerStack";

/**
* A merged-back fork changes what the next send carries, so it gets a composer
* notice. It has no dismiss: the next run consumes the transfer and the
* timeline's context handoff divider takes over.
*/
export function mergeBackBannerItem(input: {
readonly transferId: ContextTransferId;
readonly sourceThreadTitle: string | null;
readonly forkCount: number;
readonly waitsForIdle: boolean;
}): ComposerBannerStackItem {
const { blocked, ...notice } = pendingMergeBackNotice(input);
return {
id: `merge-back:${input.transferId}`,
variant: blocked ? "warning" : "info",
icon: <PullRequestGlyph.merged />,
...notice,
};
}
Loading
Loading