Skip to content
Closed
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
61 changes: 61 additions & 0 deletions apps/server/src/process/externalLauncher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import * as Layer from "effect/Layer";
import * as Path from "effect/Path";
import * as Sink from "effect/Sink";
import * as Stream from "effect/Stream";
import * as TestClock from "effect/testing/TestClock";
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";

import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
Expand Down Expand Up @@ -155,6 +156,66 @@ it.effect("discovers editors through the service API", () =>
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

it.effect("memoizes editor discovery and refreshes after the cache window", () => {
let statCalls = 0;
const fileInfo = { type: "File" } as FileSystem.File.Info;
const launcherLayer = ExternalLauncher.layer.pipe(
Layer.provide(
Layer.mergeAll(
FileSystem.layerNoop({
stat: () =>
Effect.sync(() => {
statCalls += 1;
return fileInfo;
}),
}),
Path.layer,
Layer.succeed(
ChildProcessSpawner.ChildProcessSpawner,
ChildProcessSpawner.make(() => Effect.sync(() => makeMockDetachedHandle())),
),
),
),
);

return Effect.gen(function* () {
const launcher = yield* ExternalLauncher.ExternalLauncher;

const first = yield* launcher.resolveAvailableEditors();
assert.equal(first.includes("vscode"), true);
const statCallsAfterFirstScan = statCalls;
assert.isAbove(statCallsAfterFirstScan, 0);

// Past the shared command-resolution cache TTL (30s) but within the
// discovery cache window: the memoized set is reused without any scan.
yield* TestClock.adjust("31 seconds");
const second = yield* launcher.resolveAvailableEditors();
assert.deepEqual([...second], [...first]);
assert.equal(statCalls, statCallsAfterFirstScan);

// Past the discovery cache window the next call rescans.
yield* TestClock.adjust("30 seconds");
yield* launcher.resolveAvailableEditors();
assert.isAbove(statCalls, statCallsAfterFirstScan);
}).pipe(
Effect.provide(
Layer.mergeAll(
launcherLayer,
Layer.succeed(HostProcessPlatform, "win32"),
ConfigProvider.layer(
ConfigProvider.fromEnv({
env: {
PATH: "C:\\t3-editor-discovery-cache-test",
PATHEXT: ".COM;.EXE;.BAT;.CMD",
},
}),
),
TestClock.layer(),
),
),
);
});

it.effect("rejects unknown editors through the service API", () =>
Effect.gen(function* () {
const launcher = yield* ExternalLauncher.ExternalLauncher;
Expand Down
13 changes: 12 additions & 1 deletion apps/server/src/process/externalLauncher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,12 @@ const resolveAvailableEditors = Effect.fn("externalLauncher.resolveAvailableEdit
return yield* buildAvailableEditors(platform, env);
});

// Editor discovery walks PATH for every known editor and runs for every
// client connect (the server config embeds the available editors). Memoize
// the discovered set for a bounded window so repeat connects skip even the
// per-command cache lookups in @t3tools/shared/shell.
const EDITOR_DISCOVERY_CACHE_TTL = "60 seconds";

/**
* ExternalLauncher - Service tag for browser/editor launch operations.
*/
Expand Down Expand Up @@ -443,8 +449,13 @@ export const make = Effect.gen(function* () {
Effect.provideService(Path.Path, path),
);

const cachedAvailableEditors = yield* Effect.cachedWithTTL(
provideCommandResolutionServices(resolveAvailableEditors()),
EDITOR_DISCOVERY_CACHE_TTL,
);

return ExternalLauncher.of({
resolveAvailableEditors: () => provideCommandResolutionServices(resolveAvailableEditors()),
resolveAvailableEditors: () => cachedAvailableEditors,
launchBrowser: (target) =>
launchBrowser(target).pipe(
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
Expand Down
10 changes: 6 additions & 4 deletions apps/server/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,10 +294,12 @@ const PROVIDER_STATUS_DEBOUNCE_MS = 200;

// When a resuming client's cursor is more than this many events behind the
// current head, skip the per-event catch-up replay and send a fresh shell
// snapshot instead. Replaying each intervening event costs a shell refetch;
// past this gap a single O(active-threads) snapshot is cheaper and bounded.
// Matches the event store's default page size (DEFAULT_READ_FROM_SEQUENCE_LIMIT).
const SHELL_RESUME_MAX_GAP = 1_000;
// snapshot instead. Past this gap a single O(active-threads) snapshot is
// cheaper and bounded. The shell replay is coalesced per aggregate (see
// coalesceShellStream), so its cost stays bounded well past the event store's
// default page size and a wider gap keeps wake/resume on the cheap replay
// path instead of forcing a full snapshot.
const SHELL_RESUME_MAX_GAP = 5_000;

// Same bound for thread resume. The replay reads the *global* event range and
// filters per-thread afterwards, so a stale cursor far behind the head would
Expand Down
11 changes: 10 additions & 1 deletion apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4732,12 +4732,21 @@ function ChatViewContent(props: ChatViewProps) {
isSendBusy ||
isConnecting ||
threadDetailLoading ||
activeEnvironmentUnavailable ||
sendInFlightRef.current
) {
notifyDirectAnnotationAttached();
return;
}
if (activeEnvironmentUnavailable) {
toastManager.add(
stackedThreadToast({
type: "warning",
title: "Not connected — message not sent",
description: "Reconnecting to the environment. Try again once it is connected.",
}),
);
return;
}
if (activePendingProgress) {
if (directAnnotation) {
notifyDirectAnnotationAttached();
Expand Down
1 change: 0 additions & 1 deletion apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1270,7 +1270,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
[activePendingIsResponding, activePendingProgress, activePendingResolvedAnswers],
);
const collapsedComposerPrimaryActionDisabled =
phase === "running" ||
isSendBusy ||
isSendDisabled ||
isConnecting ||
Expand Down
53 changes: 42 additions & 11 deletions apps/web/src/components/chat/ComposerPrimaryActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -134,17 +134,48 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({

if (isRunning) {
return (
<button
type="button"
className="flex size-8 cursor-pointer items-center justify-center rounded-full bg-destructive/90 text-white shadow-xs shadow-destructive/24 inset-shadow-[0_1px_--theme(--color-white/16%)] transition-all duration-150 hover:bg-destructive hover:scale-105 active:inset-shadow-[0_1px_--theme(--color-black/8%)] active:shadow-none sm:h-8 sm:w-8"
{...pointerFocusProps}
onClick={onInterrupt}
aria-label="Stop generation"
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor" aria-hidden="true">
<rect x="2" y="2" width="8" height="8" rx="1.5" />
</svg>
</button>
<div className={cn("flex items-center justify-end", compact ? "gap-1.5" : "gap-2")}>
<Button
type="submit"
size="icon"
variant="outline"
className="size-8 rounded-full"
{...pointerFocusProps}
disabled={
isSendBusy ||
isSendDisabled ||
isConnecting ||
isEnvironmentUnavailable ||
!hasSendableContent
}
aria-label="Send message to running agent"
>
{isConnecting || isSendBusy ? (
<Spinner className="size-3.5" aria-hidden="true" />
) : (
<svg className="size-3.5" viewBox="0 0 14 14" fill="none" aria-hidden="true">
<path
d="M7 11.5V2.5M7 2.5L3 6.5M7 2.5L11 6.5"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
</Button>
<button
type="button"
className="flex size-8 cursor-pointer items-center justify-center rounded-full bg-destructive/90 text-white shadow-xs shadow-destructive/24 inset-shadow-[0_1px_--theme(--color-white/16%)] transition-all duration-150 hover:bg-destructive hover:scale-105 active:inset-shadow-[0_1px_--theme(--color-black/8%)] active:shadow-none sm:h-8 sm:w-8"
{...pointerFocusProps}
onClick={onInterrupt}
aria-label="Stop generation"
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor" aria-hidden="true">
<rect x="2" y="2" width="8" height="8" rx="1.5" />
</svg>
</button>
</div>
);
}

Expand Down
Loading
Loading