Skip to content

Commit ca2f8b9

Browse files
authored
Merge pull request #10 from aaditagrawal/feat/zortos-ports
Port fuzzy search, display settings, and copilot abort fix
2 parents 2818d09 + 9c25368 commit ca2f8b9

6 files changed

Lines changed: 204 additions & 19 deletions

File tree

apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1082,6 +1082,7 @@ const make = Effect.gen(function* () {
10821082
case "turn.started":
10831083
return !conflictsWithActiveTurn;
10841084
case "turn.completed":
1085+
case "turn.aborted":
10851086
if (conflictsWithActiveTurn || missingTurnForActiveTurn) {
10861087
return false;
10871088
}
@@ -1102,12 +1103,15 @@ const make = Effect.gen(function* () {
11021103
event.type === "session.exited" ||
11031104
event.type === "thread.started" ||
11041105
event.type === "turn.started" ||
1105-
event.type === "turn.completed"
1106+
event.type === "turn.completed" ||
1107+
event.type === "turn.aborted"
11061108
) {
11071109
const nextActiveTurnId =
11081110
event.type === "turn.started"
11091111
? (eventTurnId ?? null)
1110-
: event.type === "turn.completed" || event.type === "session.exited"
1112+
: event.type === "turn.completed" ||
1113+
event.type === "turn.aborted" ||
1114+
event.type === "session.exited"
11111115
? null
11121116
: activeTurnId;
11131117
const status = (() => {
@@ -1120,6 +1124,8 @@ const make = Effect.gen(function* () {
11201124
return "stopped";
11211125
case "turn.completed":
11221126
return runtimeTurnState(event) === "failed" ? "error" : "ready";
1127+
case "turn.aborted":
1128+
return "interrupted";
11231129
case "session.started":
11241130
case "thread.started":
11251131
// Provider thread/session start notifications can arrive during an
@@ -1132,7 +1138,7 @@ const make = Effect.gen(function* () {
11321138
? (event.payload.reason ?? thread.session?.lastError ?? "Provider session error")
11331139
: event.type === "turn.completed" && runtimeTurnState(event) === "failed"
11341140
? (runtimeTurnErrorMessage(event) ?? thread.session?.lastError ?? "Turn failed")
1135-
: status === "ready"
1141+
: status === "ready" || status === "interrupted"
11361142
? null
11371143
: (thread.session?.lastError ?? null);
11381144

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

11491155
// Fall back to accumulated thread.token-usage.updated data
11501156
// for providers (Copilot, Amp) that emit usage separately.
1151-
if (!turnUsage && event.type === "turn.completed") {
1157+
if (!turnUsage && (event.type === "turn.completed" || event.type === "turn.aborted")) {
11521158
const pending = pendingTokenUsageByThread.get(event.threadId);
11531159
if (pending) {
11541160
turnUsage = pending;
11551161
}
11561162
}
1157-
if (event.type === "turn.completed") {
1163+
if (event.type === "turn.completed" || event.type === "turn.aborted") {
11581164
pendingTokenUsageByThread.delete(event.threadId);
11591165
}
11601166

@@ -1337,7 +1343,10 @@ const make = Effect.gen(function* () {
13371343
});
13381344
}
13391345

1340-
if (event.type === "turn.completed") {
1346+
if (
1347+
(event.type === "turn.completed" || event.type === "turn.aborted") &&
1348+
shouldApplyThreadLifecycle
1349+
) {
13411350
const turnId = toTurnId(event.turnId);
13421351
if (turnId) {
13431352
const assistantMessageIds = yield* getAssistantMessageIdsForTurn(thread.id, turnId);

apps/server/src/workspaceEntries.ts

Lines changed: 116 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,44 @@ function normalizeQuery(input: string): string {
6262
.toLowerCase();
6363
}
6464

65+
/**
66+
* Score a fuzzy subsequence match of `query` against `value`.
67+
* Returns a numeric penalty (lower = better) or `null` if the query
68+
* characters do not appear as a subsequence in order.
69+
*/
70+
function scoreSubsequenceMatch(query: string, value: string): number | null {
71+
let queryIndex = 0;
72+
let firstMatchIndex = -1;
73+
let lastMatchIndex = -1;
74+
let gapPenalty = 0;
75+
let prevMatchIndex = -1;
76+
77+
for (let i = 0; i < value.length && queryIndex < query.length; i++) {
78+
if (value[i] === query[queryIndex]) {
79+
if (firstMatchIndex === -1) {
80+
firstMatchIndex = i;
81+
}
82+
if (prevMatchIndex !== -1) {
83+
const gap = i - prevMatchIndex - 1;
84+
if (gap > 0) {
85+
gapPenalty += gap;
86+
}
87+
}
88+
prevMatchIndex = i;
89+
lastMatchIndex = i;
90+
queryIndex++;
91+
}
92+
}
93+
94+
if (queryIndex < query.length) {
95+
return null;
96+
}
97+
98+
const spanPenalty = lastMatchIndex - firstMatchIndex - query.length + 1;
99+
const lengthPenalty = Math.min(value.length, 64);
100+
return firstMatchIndex * 2 + gapPenalty * 3 + spanPenalty + lengthPenalty;
101+
}
102+
65103
function scoreEntry(entry: ProjectEntry, query: string): number {
66104
if (!query) {
67105
return entry.kind === "directory" ? 0 : 1;
@@ -75,7 +113,16 @@ function scoreEntry(entry: ProjectEntry, query: string): number {
75113
if (normalizedName.startsWith(query)) return 2;
76114
if (normalizedPath.startsWith(query)) return 3;
77115
if (normalizedPath.includes(`/${query}`)) return 4;
78-
return 5;
116+
if (normalizedName.includes(query)) return 5;
117+
if (normalizedPath.includes(query)) return 6;
118+
119+
const nameFuzzy = scoreSubsequenceMatch(query, normalizedName);
120+
if (nameFuzzy !== null) return 100 + nameFuzzy;
121+
122+
const pathFuzzy = scoreSubsequenceMatch(query, normalizedPath);
123+
if (pathFuzzy !== null) return 200 + pathFuzzy;
124+
125+
return Infinity;
79126
}
80127

81128
function isPathInIgnoredDirectory(relativePath: string): boolean {
@@ -419,23 +466,80 @@ export function clearWorkspaceIndexCache(cwd: string): void {
419466
inFlightWorkspaceIndexBuilds.delete(cwd);
420467
}
421468

469+
function compareRankedEntries(
470+
left: { entry: ProjectEntry; score: number },
471+
right: { entry: ProjectEntry; score: number },
472+
): number {
473+
return left.score - right.score || left.entry.path.localeCompare(right.entry.path);
474+
}
475+
476+
function findInsertionIndex(
477+
ranked: Array<{ entry: ProjectEntry; score: number }>,
478+
candidate: { entry: ProjectEntry; score: number },
479+
): number {
480+
let lo = 0;
481+
let hi = ranked.length;
482+
while (lo < hi) {
483+
const mid = (lo + hi) >>> 1;
484+
if (compareRankedEntries(ranked[mid]!, candidate) <= 0) {
485+
lo = mid + 1;
486+
} else {
487+
hi = mid;
488+
}
489+
}
490+
return lo;
491+
}
492+
493+
function insertRankedEntry(
494+
ranked: Array<{ entry: ProjectEntry; score: number }>,
495+
entry: ProjectEntry,
496+
score: number,
497+
limit: number,
498+
): void {
499+
if (limit <= 0) {
500+
return;
501+
}
502+
const candidate = { entry, score };
503+
if (ranked.length >= limit && compareRankedEntries(candidate, ranked[ranked.length - 1]!) >= 0) {
504+
return;
505+
}
506+
const index = findInsertionIndex(ranked, candidate);
507+
ranked.splice(index, 0, candidate);
508+
if (ranked.length > limit) {
509+
ranked.pop();
510+
}
511+
}
512+
422513
export async function searchWorkspaceEntries(
423514
input: ProjectSearchEntriesInput,
424515
): Promise<ProjectSearchEntriesResult> {
425516
const index = await getWorkspaceIndex(input.cwd);
426517
const normalizedQuery = normalizeQuery(input.query);
427-
const candidates = normalizedQuery
428-
? index.entries.filter((entry) => entry.path.toLowerCase().includes(normalizedQuery))
429-
: index.entries;
430-
431-
const ranked = candidates.toSorted((left, right) => {
432-
const scoreDelta = scoreEntry(left, normalizedQuery) - scoreEntry(right, normalizedQuery);
433-
if (scoreDelta !== 0) return scoreDelta;
434-
return left.path.localeCompare(right.path);
435-
});
518+
519+
if (!normalizedQuery) {
520+
const ranked = index.entries.toSorted((left, right) => {
521+
const scoreDelta = scoreEntry(left, normalizedQuery) - scoreEntry(right, normalizedQuery);
522+
if (scoreDelta !== 0) return scoreDelta;
523+
return left.path.localeCompare(right.path);
524+
});
525+
return {
526+
entries: ranked.slice(0, input.limit),
527+
truncated: index.truncated || ranked.length > input.limit,
528+
};
529+
}
530+
531+
const ranked: Array<{ entry: ProjectEntry; score: number }> = [];
532+
let matchedEntryCount = 0;
533+
534+
for (const entry of index.entries) {
535+
const score = scoreEntry(entry, normalizedQuery);
536+
if (!Number.isFinite(score)) continue;
537+
matchedEntryCount++;
538+
insertRankedEntry(ranked, entry, score, input.limit);
539+
}
436540

437541
return {
438-
entries: ranked.slice(0, input.limit),
439-
truncated: index.truncated || ranked.length > input.limit,
542+
entries: ranked.map((item) => item.entry),
543+
truncated: index.truncated || matchedEntryCount > input.limit,
440544
};
441545
}

apps/web/src/appSettings.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ const AppSettingsSchema = Schema.Struct({
5555
enableAssistantStreaming: Schema.Boolean.pipe(
5656
Schema.withConstructorDefault(() => Option.some(false)),
5757
),
58+
showCommandOutput: Schema.Boolean.pipe(Schema.withConstructorDefault(() => Option.some(true))),
59+
showFileChangeDiffs: Schema.Boolean.pipe(Schema.withConstructorDefault(() => Option.some(true))),
5860
customCodexModels: Schema.Array(Schema.String).pipe(
5961
Schema.withConstructorDefault(() => Option.some([])),
6062
),

apps/web/src/components/DiffPanel.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { useTheme } from "../hooks/useTheme";
2323
import { buildPatchCacheKey } from "../lib/diffRendering";
2424
import { resolveDiffThemeName } from "../lib/diffRendering";
2525
import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
26+
import { useAppSettings } from "../appSettings";
2627
import { useStore } from "../store";
2728
import { ToggleGroup, Toggle } from "./ui/toggle-group";
2829

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

165166
export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
166167
const navigate = useNavigate();
168+
const { settings } = useAppSettings();
167169
const { resolvedTheme } = useTheme();
168170
const [diffRenderMode, setDiffRenderMode] = useState<DiffRenderMode>("stacked");
169171
const patchViewportRef = useRef<HTMLDivElement>(null);
@@ -546,6 +548,10 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
546548
<div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70">
547549
Turn diffs are unavailable because this project is not a git repository.
548550
</div>
551+
) : !settings.showFileChangeDiffs ? (
552+
<div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70">
553+
File change diffs are hidden in settings.
554+
</div>
549555
) : orderedTurnDiffSummaries.length === 0 ? (
550556
<div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70">
551557
No completed turns yet.

apps/web/src/components/chat/MessagesTimeline.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
useVirtualizer,
77
} from "@tanstack/react-virtual";
88
import { deriveTimelineEntries, formatElapsed, formatTimestamp } from "../../session-logic";
9+
import { useAppSettings } from "../../appSettings";
910
import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX } from "../../chat-scroll";
1011
import { type TurnDiffSummary } from "../../types";
1112
import { summarizeTurnDiffStats } from "../../lib/turnDiffTree";
@@ -68,6 +69,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
6869
resolvedTheme,
6970
workspaceRoot,
7071
}: MessagesTimelineProps) {
72+
const { settings } = useAppSettings();
7173
const timelineRootRef = useRef<HTMLDivElement | null>(null);
7274
const [timelineWidthPx, setTimelineWidthPx] = useState<number | null>(null);
7375

@@ -311,7 +313,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
311313
<p className={`text-[11px] leading-relaxed ${workToneClass(workEntry.tone)}`}>
312314
{workEntry.label}
313315
</p>
314-
{workEntry.command && (
316+
{workEntry.command && settings.showCommandOutput && (
315317
<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">
316318
{workEntry.command}
317319
</pre>
@@ -335,6 +337,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
335337
</div>
336338
)}
337339
{workEntry.detail &&
340+
settings.showCommandOutput &&
338341
(!workEntry.command || workEntry.detail !== workEntry.command) && (
339342
<p
340343
className="mt-1 text-[11px] leading-relaxed text-muted-foreground/75"

apps/web/src/routes/_chat.settings.tsx

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -746,6 +746,67 @@ function SettingsRouteView() {
746746
) : null}
747747
</section>
748748

749+
<section className="rounded-2xl border border-border bg-card p-5">
750+
<div className="mb-4">
751+
<h2 className="text-sm font-medium text-foreground">Display</h2>
752+
<p className="mt-1 text-xs text-muted-foreground">
753+
Control which elements are visible in the chat timeline.
754+
</p>
755+
</div>
756+
757+
<div className="space-y-3">
758+
<div className="flex items-center justify-between rounded-lg border border-border bg-background px-3 py-2">
759+
<div>
760+
<p className="text-sm font-medium text-foreground">Show command output</p>
761+
<p className="text-xs text-muted-foreground">
762+
Display stdout/stderr inline after executed commands.
763+
</p>
764+
</div>
765+
<Switch
766+
checked={settings.showCommandOutput}
767+
onCheckedChange={(checked) =>
768+
updateSettings({ showCommandOutput: Boolean(checked) })
769+
}
770+
aria-label="Show command output"
771+
/>
772+
</div>
773+
774+
<div className="flex items-center justify-between rounded-lg border border-border bg-background px-3 py-2">
775+
<div>
776+
<p className="text-sm font-medium text-foreground">Show file change diffs</p>
777+
<p className="text-xs text-muted-foreground">
778+
Render file diffs in the side panel after completed turns.
779+
</p>
780+
</div>
781+
<Switch
782+
checked={settings.showFileChangeDiffs}
783+
onCheckedChange={(checked) =>
784+
updateSettings({ showFileChangeDiffs: Boolean(checked) })
785+
}
786+
aria-label="Show file change diffs"
787+
/>
788+
</div>
789+
790+
{settings.showCommandOutput !== defaults.showCommandOutput ||
791+
settings.showFileChangeDiffs !== defaults.showFileChangeDiffs ? (
792+
<div className="flex justify-end">
793+
<Button
794+
size="xs"
795+
variant="outline"
796+
onClick={() =>
797+
updateSettings({
798+
showCommandOutput: defaults.showCommandOutput,
799+
showFileChangeDiffs: defaults.showFileChangeDiffs,
800+
})
801+
}
802+
>
803+
Restore defaults
804+
</Button>
805+
</div>
806+
) : null}
807+
</div>
808+
</section>
809+
749810
<section className="rounded-2xl border border-border bg-card p-5">
750811
<div className="mb-4">
751812
<h2 className="text-sm font-medium text-foreground">Keybindings</h2>

0 commit comments

Comments
 (0)