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
21 changes: 15 additions & 6 deletions apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1082,6 +1082,7 @@ const make = Effect.gen(function* () {
case "turn.started":
return !conflictsWithActiveTurn;
case "turn.completed":
case "turn.aborted":
if (conflictsWithActiveTurn || missingTurnForActiveTurn) {
return false;
}
Expand All @@ -1102,12 +1103,15 @@ const make = Effect.gen(function* () {
event.type === "session.exited" ||
event.type === "thread.started" ||
event.type === "turn.started" ||
event.type === "turn.completed"
event.type === "turn.completed" ||
event.type === "turn.aborted"
) {
const nextActiveTurnId =
event.type === "turn.started"
? (eventTurnId ?? null)
: event.type === "turn.completed" || event.type === "session.exited"
: event.type === "turn.completed" ||
event.type === "turn.aborted" ||
event.type === "session.exited"
? null
: activeTurnId;
const status = (() => {
Expand All @@ -1120,6 +1124,8 @@ const make = Effect.gen(function* () {
return "stopped";
case "turn.completed":
return runtimeTurnState(event) === "failed" ? "error" : "ready";
case "turn.aborted":
return "interrupted";
case "session.started":
case "thread.started":
// Provider thread/session start notifications can arrive during an
Expand All @@ -1132,7 +1138,7 @@ const make = Effect.gen(function* () {
? (event.payload.reason ?? thread.session?.lastError ?? "Provider session error")
: event.type === "turn.completed" && runtimeTurnState(event) === "failed"
? (runtimeTurnErrorMessage(event) ?? thread.session?.lastError ?? "Turn failed")
: status === "ready"
: status === "ready" || status === "interrupted"
? null
: (thread.session?.lastError ?? null);

Expand All @@ -1148,13 +1154,13 @@ const make = Effect.gen(function* () {

// Fall back to accumulated thread.token-usage.updated data
// for providers (Copilot, Amp) that emit usage separately.
if (!turnUsage && event.type === "turn.completed") {
if (!turnUsage && (event.type === "turn.completed" || event.type === "turn.aborted")) {
const pending = pendingTokenUsageByThread.get(event.threadId);
if (pending) {
turnUsage = pending;
}
}
if (event.type === "turn.completed") {
if (event.type === "turn.completed" || event.type === "turn.aborted") {
pendingTokenUsageByThread.delete(event.threadId);
}

Expand Down Expand Up @@ -1337,7 +1343,10 @@ const make = Effect.gen(function* () {
});
}

if (event.type === "turn.completed") {
if (
(event.type === "turn.completed" || event.type === "turn.aborted") &&
shouldApplyThreadLifecycle
) {
const turnId = toTurnId(event.turnId);
if (turnId) {
const assistantMessageIds = yield* getAssistantMessageIdsForTurn(thread.id, turnId);
Expand Down
128 changes: 116 additions & 12 deletions apps/server/src/workspaceEntries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,44 @@ function normalizeQuery(input: string): string {
.toLowerCase();
}

/**
* Score a fuzzy subsequence match of `query` against `value`.
* Returns a numeric penalty (lower = better) or `null` if the query
* characters do not appear as a subsequence in order.
*/
function scoreSubsequenceMatch(query: string, value: string): number | null {
let queryIndex = 0;
let firstMatchIndex = -1;
let lastMatchIndex = -1;
let gapPenalty = 0;
let prevMatchIndex = -1;

for (let i = 0; i < value.length && queryIndex < query.length; i++) {
if (value[i] === query[queryIndex]) {
if (firstMatchIndex === -1) {
firstMatchIndex = i;
}
if (prevMatchIndex !== -1) {
const gap = i - prevMatchIndex - 1;
if (gap > 0) {
gapPenalty += gap;
}
}
prevMatchIndex = i;
lastMatchIndex = i;
queryIndex++;
}
}

if (queryIndex < query.length) {
return null;
}

const spanPenalty = lastMatchIndex - firstMatchIndex - query.length + 1;
const lengthPenalty = Math.min(value.length, 64);
return firstMatchIndex * 2 + gapPenalty * 3 + spanPenalty + lengthPenalty;
}

function scoreEntry(entry: ProjectEntry, query: string): number {
if (!query) {
return entry.kind === "directory" ? 0 : 1;
Expand All @@ -75,7 +113,16 @@ function scoreEntry(entry: ProjectEntry, query: string): number {
if (normalizedName.startsWith(query)) return 2;
if (normalizedPath.startsWith(query)) return 3;
if (normalizedPath.includes(`/${query}`)) return 4;
return 5;
if (normalizedName.includes(query)) return 5;
if (normalizedPath.includes(query)) return 6;

const nameFuzzy = scoreSubsequenceMatch(query, normalizedName);
if (nameFuzzy !== null) return 100 + nameFuzzy;

const pathFuzzy = scoreSubsequenceMatch(query, normalizedPath);
if (pathFuzzy !== null) return 200 + pathFuzzy;

return Infinity;
}

function isPathInIgnoredDirectory(relativePath: string): boolean {
Expand Down Expand Up @@ -419,23 +466,80 @@ export function clearWorkspaceIndexCache(cwd: string): void {
inFlightWorkspaceIndexBuilds.delete(cwd);
}

function compareRankedEntries(
left: { entry: ProjectEntry; score: number },
right: { entry: ProjectEntry; score: number },
): number {
return left.score - right.score || left.entry.path.localeCompare(right.entry.path);
}

function findInsertionIndex(
ranked: Array<{ entry: ProjectEntry; score: number }>,
candidate: { entry: ProjectEntry; score: number },
): number {
let lo = 0;
let hi = ranked.length;
while (lo < hi) {
const mid = (lo + hi) >>> 1;
if (compareRankedEntries(ranked[mid]!, candidate) <= 0) {
lo = mid + 1;
} else {
hi = mid;
}
}
return lo;
}

function insertRankedEntry(
ranked: Array<{ entry: ProjectEntry; score: number }>,
entry: ProjectEntry,
score: number,
limit: number,
): void {
if (limit <= 0) {
return;
}
const candidate = { entry, score };
if (ranked.length >= limit && compareRankedEntries(candidate, ranked[ranked.length - 1]!) >= 0) {
return;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
const index = findInsertionIndex(ranked, candidate);
ranked.splice(index, 0, candidate);
if (ranked.length > limit) {
ranked.pop();
}
}

export async function searchWorkspaceEntries(
input: ProjectSearchEntriesInput,
): Promise<ProjectSearchEntriesResult> {
const index = await getWorkspaceIndex(input.cwd);
const normalizedQuery = normalizeQuery(input.query);
const candidates = normalizedQuery
? index.entries.filter((entry) => entry.path.toLowerCase().includes(normalizedQuery))
: index.entries;

const ranked = candidates.toSorted((left, right) => {
const scoreDelta = scoreEntry(left, normalizedQuery) - scoreEntry(right, normalizedQuery);
if (scoreDelta !== 0) return scoreDelta;
return left.path.localeCompare(right.path);
});

if (!normalizedQuery) {
const ranked = index.entries.toSorted((left, right) => {
const scoreDelta = scoreEntry(left, normalizedQuery) - scoreEntry(right, normalizedQuery);
if (scoreDelta !== 0) return scoreDelta;
return left.path.localeCompare(right.path);
});
return {
entries: ranked.slice(0, input.limit),
truncated: index.truncated || ranked.length > input.limit,
};
}

const ranked: Array<{ entry: ProjectEntry; score: number }> = [];
let matchedEntryCount = 0;

for (const entry of index.entries) {
const score = scoreEntry(entry, normalizedQuery);
if (!Number.isFinite(score)) continue;
matchedEntryCount++;
insertRankedEntry(ranked, entry, score, input.limit);
}

return {
entries: ranked.slice(0, input.limit),
truncated: index.truncated || ranked.length > input.limit,
entries: ranked.map((item) => item.entry),
truncated: index.truncated || matchedEntryCount > input.limit,
};
}
2 changes: 2 additions & 0 deletions apps/web/src/appSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ const AppSettingsSchema = Schema.Struct({
enableAssistantStreaming: Schema.Boolean.pipe(
Schema.withConstructorDefault(() => Option.some(false)),
),
showCommandOutput: Schema.Boolean.pipe(Schema.withConstructorDefault(() => Option.some(true))),
showFileChangeDiffs: Schema.Boolean.pipe(Schema.withConstructorDefault(() => Option.some(true))),
customCodexModels: Schema.Array(Schema.String).pipe(
Schema.withConstructorDefault(() => Option.some([])),
),
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/components/DiffPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { useTheme } from "../hooks/useTheme";
import { buildPatchCacheKey } from "../lib/diffRendering";
import { resolveDiffThemeName } from "../lib/diffRendering";
import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
import { useAppSettings } from "../appSettings";
import { useStore } from "../store";
import { ToggleGroup, Toggle } from "./ui/toggle-group";

Expand Down Expand Up @@ -164,6 +165,7 @@ export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider";

export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
const navigate = useNavigate();
const { settings } = useAppSettings();
const { resolvedTheme } = useTheme();
const [diffRenderMode, setDiffRenderMode] = useState<DiffRenderMode>("stacked");
const patchViewportRef = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -546,6 +548,10 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
<div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70">
Turn diffs are unavailable because this project is not a git repository.
</div>
) : !settings.showFileChangeDiffs ? (
<div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70">
File change diffs are hidden in settings.
</div>
) : orderedTurnDiffSummaries.length === 0 ? (
<div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70">
No completed turns yet.
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
useVirtualizer,
} from "@tanstack/react-virtual";
import { deriveTimelineEntries, formatElapsed, formatTimestamp } from "../../session-logic";
import { useAppSettings } from "../../appSettings";
import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX } from "../../chat-scroll";
import { type TurnDiffSummary } from "../../types";
import { summarizeTurnDiffStats } from "../../lib/turnDiffTree";
Expand Down Expand Up @@ -68,6 +69,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
resolvedTheme,
workspaceRoot,
}: MessagesTimelineProps) {
const { settings } = useAppSettings();
const timelineRootRef = useRef<HTMLDivElement | null>(null);
const [timelineWidthPx, setTimelineWidthPx] = useState<number | null>(null);

Expand Down Expand Up @@ -311,7 +313,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
<p className={`text-[11px] leading-relaxed ${workToneClass(workEntry.tone)}`}>
{workEntry.label}
</p>
{workEntry.command && (
{workEntry.command && settings.showCommandOutput && (
<pre className="mt-1 overflow-x-auto rounded-md border border-border/70 bg-background/80 px-2 py-1 font-mono text-[11px] leading-relaxed text-foreground/80">
{workEntry.command}
</pre>
Expand All @@ -335,6 +337,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
</div>
)}
{workEntry.detail &&
settings.showCommandOutput &&
(!workEntry.command || workEntry.detail !== workEntry.command) && (
<p
className="mt-1 text-[11px] leading-relaxed text-muted-foreground/75"
Expand Down
61 changes: 61 additions & 0 deletions apps/web/src/routes/_chat.settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -746,6 +746,67 @@ function SettingsRouteView() {
) : null}
</section>

<section className="rounded-2xl border border-border bg-card p-5">
<div className="mb-4">
<h2 className="text-sm font-medium text-foreground">Display</h2>
<p className="mt-1 text-xs text-muted-foreground">
Control which elements are visible in the chat timeline.
</p>
</div>

<div className="space-y-3">
<div className="flex items-center justify-between rounded-lg border border-border bg-background px-3 py-2">
<div>
<p className="text-sm font-medium text-foreground">Show command output</p>
<p className="text-xs text-muted-foreground">
Display stdout/stderr inline after executed commands.
</p>
</div>
<Switch
checked={settings.showCommandOutput}
onCheckedChange={(checked) =>
updateSettings({ showCommandOutput: Boolean(checked) })
}
aria-label="Show command output"
/>
</div>

<div className="flex items-center justify-between rounded-lg border border-border bg-background px-3 py-2">
<div>
<p className="text-sm font-medium text-foreground">Show file change diffs</p>
<p className="text-xs text-muted-foreground">
Render file diffs in the side panel after completed turns.
</p>
</div>
<Switch
checked={settings.showFileChangeDiffs}
onCheckedChange={(checked) =>
updateSettings({ showFileChangeDiffs: Boolean(checked) })
}
aria-label="Show file change diffs"
/>
</div>

{settings.showCommandOutput !== defaults.showCommandOutput ||
settings.showFileChangeDiffs !== defaults.showFileChangeDiffs ? (
<div className="flex justify-end">
<Button
size="xs"
variant="outline"
onClick={() =>
updateSettings({
showCommandOutput: defaults.showCommandOutput,
showFileChangeDiffs: defaults.showFileChangeDiffs,
})
}
>
Restore defaults
</Button>
</div>
) : null}
</div>
</section>

<section className="rounded-2xl border border-border bg-card p-5">
<div className="mb-4">
<h2 className="text-sm font-medium text-foreground">Keybindings</h2>
Expand Down
Loading