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
2 changes: 1 addition & 1 deletion apps/desktop/src/preview/AnnotationStyles.generated.ts

Large diffs are not rendered by default.

17 changes: 15 additions & 2 deletions apps/desktop/src/preview/PickPreload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -633,6 +633,8 @@ function startAnnotation(
" h-7 w-7 shrink-0 rounded-full border-primary bg-primary p-0 text-primary-foreground hover:bg-primary/90";
const voiceSkip = createButton("Skip", "Skip transcript cleanup");
voiceSkip.className += " h-7 shrink-0 rounded-full px-2.5";
// createButton's rounded-md sorts after rounded-full in the generated CSS, so it would win.
for (const button of [voiceCancel, voiceFinish, voiceSkip]) button.classList.remove("rounded-md");
const voiceStatus = document.createElement("div");
voiceStatus.className = "flex h-7 min-w-0 flex-1 items-center gap-2";
voiceStatus.setAttribute("role", "status");
Expand Down Expand Up @@ -744,16 +746,27 @@ function startAnnotation(
}
const recording = state.phase === "recording";
const active = state.phase !== "idle" && state.phase !== "error";
const wasActive = voicePill.parentElement === composerRow;
voiceButton.hidden = !state.available || active;
voiceButton.disabled = voiceBusy;
const label = voiceSettings ? "Open voice settings" : "Start voice input";
voiceButton.title =
!voiceSettings && state.shortcutLabel ? `${label} (${state.shortcutLabel})` : label;
voiceButton.setAttribute("aria-label", label);
voiceRow.hidden = state.phase === "idle";
// An active dictation takes the textarea's place; it takes focus once (sr-only) so Enter still attaches.
voiceRow.hidden = state.phase === "idle" || active;
comment.classList.toggle("sr-only", active);
if (active && !wasActive) comment.focus({ preventScroll: true });
submit.hidden = active;
// Re-inserting on every level update would blur a focused Finish or Cancel.
if (active && voicePill.parentElement !== composerRow) comment.after(voicePill);
else if (!active && voicePill.parentElement !== voiceRow) voiceRow.appendChild(voicePill);
voicePill.className = active
? "flex h-9 w-64 min-w-0 items-center gap-2 rounded-full border border-border bg-background p-1"
? "flex h-8 min-w-0 flex-1 items-center gap-2"
: "flex min-w-0 items-center gap-1";
// A draft that arrived while the textarea was sr-only was measured at 1px wide.
if (wasActive && !active) resizeComment();
else if (phaseChanged) queueEditorLayout();
voiceCancel.title =
state.phase === "error" ? "Dismiss voice input error" : "Cancel voice input";
voiceCancel.setAttribute("aria-label", voiceCancel.title);
Expand Down
135 changes: 90 additions & 45 deletions apps/mobile/src/features/pull-requests/PullRequestMarkdown.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { splitPullRequestBody } from "@t3tools/client-runtime/pull-request-markdown";
import {
splitPullRequestBody,
type PullRequestBodySegment,
} from "@t3tools/client-runtime/pull-request-markdown";
import { markdownTaskMarkerOffset } from "@t3tools/shared/markdownTaskList";
import type { ReactNode } from "react";
import { View } from "react-native";
Expand Down Expand Up @@ -116,49 +119,101 @@ export function PullRequestMarkdown(props: {
readonly checked: boolean;
}) => boolean;
}) {
const { onTaskListChange } = props;
const mediaContext = useContext(PullRequestMediaContext);
const changeRequestUrl = useContext(ChangeRequestUrlContext);
const segments = useMemo(() => splitPullRequestBody(props.markdown), [props.markdown]);
const [undoneTicks, setUndoneTicks] = useState(0);
const gallery: FilePreviewItem[] = markdownImageSources(
props.imageGroupMarkdown ?? props.markdown,
)
.map((href) => (changeRequestUrl ? resolveChangeRequestMediaUrl(href, changeRequestUrl) : href))
.filter(
(uri) =>
/^https?:\/\//i.test(uri) &&
videoMimeType({ name: uri.split(/[?#]/)[0] ?? uri, mimeType: "" }) === null,
)
.map((uri) => pullRequestImageSource(uri, mediaContext));
const segmentProps = {
gallery,
lineBreaks: props.lineBreaks,
undoneTicks,
onTaskListChange: props.onTaskListChange,
onTaskListUndone: () => setUndoneTicks((count) => count + 1),
};
return (
<View style={{ gap: 12 }}>
{segments.map((segment) => (
<PullRequestBodySegmentView key={segment.id} segment={segment} {...segmentProps} />
))}
</View>
);
}

function PullRequestBodySegmentView(props: {
readonly segment: PullRequestBodySegment;
readonly gallery: FilePreviewItem[];
readonly lineBreaks: boolean | undefined;
readonly undoneTicks: number;
readonly onTaskListChange:
| ((input: { readonly markerOffset: number; readonly checked: boolean }) => boolean)
| undefined;
readonly onTaskListUndone: () => void;
}) {
const { segment, onTaskListChange } = props;
if (segment.kind === "markdown") {
return (
<PullRequestMarkdownContent
key={props.undoneTicks}
markdown={segment.text}
gallery={props.gallery}
lineBreaks={props.lineBreaks}
onTaskListItemPress={
onTaskListChange
? (event) => {
const offset = markdownTaskMarkerOffset(
segment.text,
event.index,
event.text,
event.checked,
);
const kept =
offset !== null &&
onTaskListChange({
markerOffset: segment.sourceOffset + offset,
checked: event.checked,
});
if (!kept) props.onTaskListUndone();
}
: undefined
}
/>
);
}
if (segment.kind === "attachment") {
return <PullRequestMedia url={segment.url} kind={segment.media} />;
}
return (
<View style={{ gap: 12 }}>
{segments.map((segment) =>
segment.kind === "markdown" ? (
<PullRequestMarkdownContent
key={`${segment.id}:${undoneTicks}`}
markdown={segment.text}
imageGroupMarkdown={props.imageGroupMarkdown ?? props.markdown}
lineBreaks={props.lineBreaks}
onTaskListItemPress={
onTaskListChange
? (event) => {
const offset = markdownTaskMarkerOffset(
segment.text,
event.index,
event.text,
event.checked,
);
const kept =
offset !== null &&
onTaskListChange({
markerOffset: segment.sourceOffset + offset,
checked: event.checked,
});
if (!kept) setUndoneTicks((count) => count + 1);
}
: undefined
}
/>
) : (
<PullRequestMedia key={segment.id} url={segment.url} kind={segment.media} />
),
)}
{segment.rows.map((cells, rowIndex) => (
// oxlint-disable-next-line react/no-array-index-key -- rows have no identity besides position
<View key={rowIndex} style={{ flexDirection: "row", gap: 12 }}>
{cells.map((cell, cellIndex) => (
// oxlint-disable-next-line react/no-array-index-key -- cells have no identity besides position
<View key={cellIndex} style={{ flex: 1, minWidth: 0, gap: 12 }}>
{cell.segments.map((cellSegment) => (
<PullRequestBodySegmentView key={cellSegment.id} {...props} segment={cellSegment} />
))}
</View>
))}
</View>
))}
</View>
);
}

function PullRequestMarkdownContent(props: {
readonly markdown: string;
readonly imageGroupMarkdown?: string;
readonly gallery: FilePreviewItem[];
readonly lineBreaks: boolean | undefined;
readonly onTaskListItemPress?: SelectableMarkdownTextProps["onTaskListItemPress"];
}) {
Expand All @@ -173,23 +228,13 @@ function PullRequestMarkdownContent(props: {
const openLink = (href: string) => {
void openPullRequest(href);
};
const gallery: FilePreviewItem[] = markdownImageSources(
props.imageGroupMarkdown ?? props.markdown,
)
.map(resolveMediaUrl)
.filter(
(uri) =>
/^https?:\/\//i.test(uri) &&
videoMimeType({ name: uri.split(/[?#]/)[0] ?? uri, mimeType: "" }) === null,
)
.map((uri) => pullRequestImageSource(uri, mediaContext));
const [preview, setPreview] = useState<FilePreviewSource | null>(null);
const renderImage: MarkdownImageRenderer = (image) => (
<MarkdownImage href={resolveMediaUrl(image.href)} alt={image.alt} onPreview={setPreview} />
);
const styles = useMarkdownPreviewStyles();
return (
<FilePreviewGallery value={gallery}>
<FilePreviewGallery value={props.gallery}>
<SelectableMarkdownText
markdown={markdown}
preserveSoftBreaks={props.lineBreaks ?? hostLineBreaks}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,16 @@ describe("PR attachments", () => {
},
]);
});

it("collects videos from a before/after table", () => {
const media = splitPullRequestBody(
"<table>\n<tr><th>Before</th><th>After</th></tr>\n<tr><td>\n\nhttps://drop.example.com/before.mp4\n\n</td><td>\n\nhttps://drop.example.com/after.mp4\n\n</td></tr>\n</table>",
);
expect(
pullRequestDescriptionContent({ type: "root" } as MarkdownNode, media).attachments,
).toEqual([
{ uri: "https://drop.example.com/before.mp4", caption: "Video", kind: "video" },
{ uri: "https://drop.example.com/after.mp4", caption: "Video", kind: "video" },
]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,11 @@ export function pullRequestDescriptionContent(
if (node.type === "break") text.push(" ");
};
visit(document);
for (const segment of media) {
const collect = (segment: PullRequestBodySegment) => {
if (segment.kind === "table") {
segment.rows.flat().forEach((cell) => cell.segments.forEach(collect));
return;
}
if (segment.kind === "attachment" && !attachments.has(segment.url)) {
const name = new URL(segment.url).pathname.split("/").pop();
attachments.set(segment.url, {
Expand All @@ -46,6 +50,7 @@ export function pullRequestDescriptionContent(
kind: segment.media,
});
}
}
};
media.forEach(collect);
return { preview: text.join("").trim(), attachments: [...attachments.values()] };
}
5 changes: 2 additions & 3 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11820,9 +11820,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
}
return false;
}),
hasPendingBackgroundWorkForThread: Effect.fn(
"ClaudeAdapterV2.hasPendingBackgroundWorkForThread",
)(function* (providerThread, options) {
// Untraced: the run's event stream calls this once per provider event.
hasPendingBackgroundWorkForThread: Effect.fnUntraced(function* (providerThread, options) {
const nativeThreadId = providerThread.nativeThreadRef?.nativeId;
if (nativeThreadId === undefined || nativeThreadId === null) {
return false;
Expand Down
5 changes: 2 additions & 3 deletions apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7077,9 +7077,8 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
}
return false;
}),
hasPendingBackgroundWorkForThread: Effect.fn(
"CodexAdapterV2.hasPendingBackgroundWorkForThread",
)(function* (providerThread) {
// Untraced: the run's event stream calls this once per provider event.
hasPendingBackgroundWorkForThread: Effect.fnUntraced(function* (providerThread) {
const contexts = [
...(yield* Ref.get(activeTurns)).values(),
...(yield* Ref.get(settledTurns)).values(),
Expand Down
56 changes: 56 additions & 0 deletions apps/server/src/orchestration-v2/ProjectionStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,62 @@ it.effect("record reads restrict provider histories to delivery identities", ()
})).providerThreads,
[],
);
const ownerNodeId = NodeId.make("node:delivery-identities:owner");
yield* store.apply({
id: EventId.make("event:delivery-identities:owner-node"),
type: "node.updated",
threadId,
runId: RunId.make("run:delivery-identities:rolled-back"),
nodeId: ownerNodeId,
driver,
occurredAt: now,
payload: {
id: ownerNodeId,
threadId,
runId: RunId.make("run:delivery-identities:rolled-back"),
rootNodeId: NodeId.make("node:delivery-identities:rolled-back"),
parentNodeId: NodeId.make("node:delivery-identities:rolled-back"),
kind: "subagent",
status: "completed",
countsForRun: false,
providerThreadId: null,
providerTurnId: null,
nativeItemRef: null,
runtimeRequestId: null,
checkpointScopeId: null,
startedAt: now,
completedAt: now,
},
});
yield* store.apply({
id: EventId.make("event:delivery-identities:owned"),
type: "provider-thread.updated",
threadId,
occurredAt: now,
payload: {
id: ProviderThreadId.make("provider-thread:owned"),
appThreadId: ThreadId.make("thread:delivery-identities:child"),
driver,
providerInstanceId,
providerSessionId: null,
ownerNodeId,
nativeThreadRef: null,
nativeConversationHeadRef: null,
lastRunOrdinal: null,
handoffIds: [],
forkedFrom: null,
status: "idle",
firstRunOrdinal: 2,
createdAt: now,
updatedAt: now,
},
});
assert.deepEqual(
(yield* store.getThreadRecords(threadId, ["providerThreads"])).providerThreads.map(
(row) => row.id,
),
["provider-thread:history", "provider-thread:selected", "provider-thread:owned"],
);
// Unselected history must not be decoded, even if its payload is unreadable.
yield* sql`UPDATE orchestration_v2_projection_provider_threads SET payload_json = '{}'
WHERE provider_thread_id = 'provider-thread:history'`;
Expand Down
22 changes: 14 additions & 8 deletions apps/server/src/orchestration-v2/ProjectionStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1981,6 +1981,16 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
OR item.payload_json LIKE '%"viewedImagePath"%'
OR item.payload_json LIKE '%"toolName":"%delegate_task"%')`;

// Start from the few owned provider threads; a thread can have a large settled node history.
const nodesOwningProviderThreads = (threadId: ThreadId) => sql`
SELECT owner.node_id
FROM orchestration_v2_projection_provider_threads AS owned
INDEXED BY orchestration_v2_projection_provider_threads_owner_idx
CROSS JOIN orchestration_v2_projection_nodes AS owner
ON owner.node_id = owned.owner_node_id
WHERE owned.owner_node_id IS NOT NULL AND owner.thread_id = ${threadId}
`;

const keepRecordedRunField = (payload: Statement.Fragment, path: string) => sql`
CASE
WHEN json_type(excluded.payload_json, ${path}) IS NULL
Expand Down Expand Up @@ -3502,11 +3512,7 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
SELECT payload_json
FROM orchestration_v2_projection_provider_threads
WHERE (thread_id = ${threadId}
OR owner_node_id IN (
SELECT node_id
FROM orchestration_v2_projection_nodes
WHERE thread_id = ${threadId}
)
OR owner_node_id IN (${nodesOwningProviderThreads(threadId)})
OR provider_thread_id IN (
SELECT provider_thread_id
FROM orchestration_v2_projection_subagents
Expand Down Expand Up @@ -5088,7 +5094,7 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
sql<PayloadRow>`SELECT payload_json FROM orchestration_v2_projection_provider_threads
WHERE provider_thread_id = ${target.providerThreadId} AND (
thread_id = ${threadId}
OR owner_node_id IN (SELECT node_id FROM orchestration_v2_projection_nodes WHERE thread_id = ${threadId})
OR owner_node_id IN (${nodesOwningProviderThreads(threadId)})
OR provider_thread_id IN (SELECT provider_thread_id FROM orchestration_v2_projection_subagents WHERE thread_id = ${threadId})
)`,
sql<PayloadRow>`SELECT payload_json FROM orchestration_v2_projection_provider_turns
Expand Down Expand Up @@ -5328,7 +5334,7 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
sql<PayloadRow>`SELECT payload_json FROM orchestration_v2_projection_provider_threads
WHERE provider_thread_id = ${run?.providerThreadId ?? null}
AND (thread_id = ${threadId}
OR owner_node_id IN (SELECT node_id FROM orchestration_v2_projection_nodes WHERE thread_id = ${threadId})
OR owner_node_id IN (${nodesOwningProviderThreads(threadId)})
OR provider_thread_id IN (SELECT provider_thread_id FROM orchestration_v2_projection_subagents WHERE thread_id = ${threadId}))`,
sql<{ readonly ordinal: number }>`SELECT ordinal_within_scope AS ordinal
FROM orchestration_v2_projection_checkpoints
Expand Down Expand Up @@ -5367,7 +5373,7 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
sql<PayloadRow>`SELECT payload_json FROM orchestration_v2_projection_provider_threads
WHERE provider_thread_id = ${target.providerThreadId}
AND (thread_id = ${threadId}
OR owner_node_id IN (SELECT node_id FROM orchestration_v2_projection_nodes WHERE thread_id = ${threadId})
OR owner_node_id IN (${nodesOwningProviderThreads(threadId)})
OR provider_thread_id IN (SELECT provider_thread_id FROM orchestration_v2_projection_subagents WHERE thread_id = ${threadId}))`,
sql<PayloadRow>`SELECT payload_json FROM orchestration_v2_projection_checkpoints
WHERE thread_id = ${threadId} AND checkpoint_id = ${target.checkpointId}`,
Expand Down
Loading
Loading