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
38 changes: 0 additions & 38 deletions .github/workflows/pages.yml

This file was deleted.

3 changes: 3 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,9 @@ jobs:
APPLE_API_KEY_BASE64: ${{ secrets.APPLE_API_KEY_BASE64 }}
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
CSC_LINK: ${{ secrets.MACOS_CERTIFICATE_P12 }}
CSC_KEY_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
run: >-
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,18 @@ It is built from two modules that share one controller API:
Electron desktop shell. Hosts the Workbench (`/agent`), consolidated
Configure surface, settings, usage, logs, and browser-facing API routes.

## Mobile companion

[KittyLitter](https://kittylitter.app) connects to Local Studio so the same
agent sessions, streaming content, reasoning, tool calls, and tool results are
available on iPhone, iPad, and Android. Pair from **Settings → Profile & phone →
Connect your phone**. The QR code and copied connection JSON are private
controller credentials; share them only with a device you trust.

See the complete pairing, version, and security guide at
[localstudio.ai/mobile](https://localstudio.ai/mobile). Mobile pairing requires
Local Studio 2.9.0 or newer and KittyLitter 1.6.0 or newer.

## What is a controller?

A controller is the backend process the UI talks to — the Bun/Hono
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/app/styles/globals/base.css
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,9 @@ a.nav-link:hover,
the pointer is over the sidebar. The 6px gutter is stable so rows do not
shift when the thumb appears. */
.sidebar-scroller {
scrollbar-width: none;
scrollbar-gutter: stable;
scrollbar-width: thin;
scrollbar-color: transparent transparent;
}
.sidebar-scroller::-webkit-scrollbar {
width: 6px;
Expand All @@ -185,7 +187,6 @@ a.nav-link:hover,
background: transparent;
}
.sidebar-scroller:hover {
scrollbar-width: thin;
scrollbar-color: color-mix(in srgb, var(--fg) 22%, transparent) transparent;
}
.sidebar-scroller:hover::-webkit-scrollbar-thumb {
Expand Down
8 changes: 6 additions & 2 deletions frontend/src/features/agent/messages/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { piEventIsSuccessfulCompaction } from "@shared/agent/pi-events";
import { cleanSessionTitle, isPlaceholderSessionTitle } from "@shared/agent/session-title";
import {
cleanSessionTitle,
isPlaceholderSessionTitle,
sessionTitleFromUserPrompt,
} from "@shared/agent/session-title";

export { cleanSessionTitle, isPlaceholderSessionTitle };
import type {
Expand Down Expand Up @@ -107,7 +111,7 @@ export function formatTokenCount(tokens: number): string {
}

export function sessionTitleFromPrompt(text: string): string {
return cleanSessionTitle(text.replace(/\s+/g, " ").trim().slice(0, 48)) || "New session";
return cleanSessionTitle(sessionTitleFromUserPrompt(text).slice(0, 48)) || "New session";
}

export function visibleUserTextFromPi(text: string): string {
Expand Down
21 changes: 21 additions & 0 deletions frontend/src/features/agent/messages/session-title.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import assert from "node:assert/strict";
import { describe, test } from "node:test";
import { sessionTitleFromPrompt } from "./helpers";

describe("sessionTitleFromPrompt", () => {
test("derives a title after the internal browser context envelope", () => {
assert.equal(
sessionTitleFromPrompt(
"<browser_context>\nA server-side browser is available.\n</browser_context>\n\nReview the release status",
),
"Review the release status",
);
});

test("preserves legitimate user text containing the context tag name", () => {
assert.equal(
sessionTitleFromPrompt("Explain <browser_context> in this XML document"),
"Explain <browser_context> in this XML document",
);
});
});
46 changes: 37 additions & 9 deletions frontend/src/features/agent/runtime/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,33 @@ const safeJsonEffect = <T>(response: Response): Effect.Effect<T, unknown> =>
catch: (error) => error,
});

const AbortSessionResponseSchema = Schema.Struct({
ok: Schema.Boolean,
cleared: Schema.Struct({
steering: Schema.Array(Schema.String),
followUp: Schema.Array(Schema.String),
}),
});

const decodeAbortSessionResponse = Schema.decodeUnknownOption(AbortSessionResponseSchema, {
onExcessProperty: "preserve",
});

export type AbortSessionResult = {
steering: string[];
followUp: string[];
};

export function parseAbortSessionResult(input: unknown): AbortSessionResult {
const decoded = decodeAbortSessionResponse(input);
return decoded._tag === "Some"
? {
steering: [...decoded.value.cleared.steering],
followUp: [...decoded.value.cleared.followUp],
}
: { steering: [], followUp: [] };
}

export function listRuntimeSessions(): Promise<RuntimeSessionSummary[]> {
return Effect.runPromise(
Effect.gen(function* () {
Expand Down Expand Up @@ -79,16 +106,17 @@ export function loadRuntimeStatus(
);
}

export function abortSession(sessionId: string): Promise<void> {
export function abortSession(sessionId: string): Promise<AbortSessionResult> {
return Effect.runPromise(
fetchEffect("/api/agent/abort", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionId }),
}).pipe(
Effect.map(() => undefined),
Effect.catch(() => Effect.succeed(undefined)),
),
Effect.gen(function* () {
const response = yield* fetchEffect("/api/agent/abort", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionId }),
});
const payload = yield* safeJsonEffect<unknown>(response);
return parseAbortSessionResult(payload);
}).pipe(Effect.catch(() => Effect.succeed({ steering: [], followUp: [] }))),
);
}

Expand Down
15 changes: 4 additions & 11 deletions frontend/src/features/agent/runtime/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export type SessionEngine = {
runtime: string,
piSessionId?: string | null,
) => Promise<api.RuntimeStatus | null>;
abortTurn: (sessionId: SessionId) => Promise<void>;
abortTurn: (sessionId: SessionId) => Promise<api.AbortSessionResult>;
loadAndReplay: (piSessionId: string, sessionId: SessionId) => Promise<void>;
/** Fetch and prepend the previous page of older history (tail paging). */
loadEarlier: (sessionId: SessionId) => Promise<void>;
Expand Down Expand Up @@ -217,7 +217,7 @@ export function useSessionEngine(deps: UseSessionEngineDeps): SessionEngine {
// and /abort has no piSessionId fallback lookup.
const runtime = sessionRuntimeController().connectionKey(sessionId);
updateSession(sessionId, (session) => ({ ...session, status: "stopping" }));
yield* Effect.tryPromise({
const cleared = yield* Effect.tryPromise({
try: () => api.abortSession(runtime),
catch: (error) => error,
});
Expand All @@ -229,6 +229,7 @@ export function useSessionEngine(deps: UseSessionEngineDeps): SessionEngine {
// last streamed text is committed before we finalize.
sessionRuntimeController().flush(sessionId);
updateSession(sessionId, settleTurnFinalizingTools);
return cleared;
}),
),
[updateSession],
Expand Down Expand Up @@ -440,15 +441,7 @@ export function useSessionEngine(deps: UseSessionEngineDeps): SessionEngine {
),
),
),
[
browserToolEnabled,
browserBackend,
cwd,
loadAndReplay,
modelId,
thinkingLevel,
updateSession,
],
[browserToolEnabled, browserBackend, cwd, loadAndReplay, modelId, thinkingLevel, updateSession],
);

const acceptsControl = useCallback(
Expand Down
65 changes: 64 additions & 1 deletion frontend/src/features/agent/ui/chat-pane-composer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import {
useCallback,
useMemo,
useRef,
type ChangeEvent,
type ClipboardEvent,
type Dispatch,
Expand Down Expand Up @@ -29,6 +30,11 @@ import {
imageFileFromDataUrlText,
} from "@/features/agent/ui/chat-attachments";
import { useMountSubscription } from "@/hooks/use-mount-subscription";
import {
recentComposerHistory,
stepComposerHistory,
type ComposerHistoryCursor,
} from "@/features/agent/ui/composer-history";

export type UpdateTab = (tabId: string, patch: (tab: SessionTab) => SessionTab) => void;

Expand Down Expand Up @@ -162,6 +168,11 @@ export function useComposerTextareaBehavior({
abortTurn: () => Promise<void>;
attachFiles: (files: FileList | File[] | null) => Promise<void>;
}) {
const historyNavigationRef = useRef<{
sessionId: string;
cursor: ComposerHistoryCursor;
}>({ sessionId: "", cursor: { index: -1, draft: "" } });

const resizeAfterCommit = useCallback(
(nextValue: string, nextCaret: number) => {
requestAnimationFrame(() => {
Expand Down Expand Up @@ -213,6 +224,10 @@ export function useComposerTextareaBehavior({
(event: ChangeEvent<HTMLTextAreaElement>) => {
const value = event.target.value;
if (!activeTab) return;
historyNavigationRef.current = {
sessionId: activeTab.id,
cursor: { index: -1, draft: value },
};
updateTab(activeTab.id, (tab) => ({ ...tab, input: value }));
setMention(value ? detectComposerMention(value, event.currentTarget.selectionStart) : null);
const element = event.currentTarget;
Expand Down Expand Up @@ -267,9 +282,49 @@ export function useComposerTextareaBehavior({
[mentionIndex, mentionRows, selectMentionRow, setMention, setMentionIndex],
);

const handleComposerHistoryKey = useCallback(
(event: KeyboardEvent<HTMLTextAreaElement>): boolean => {
if (
!activeTab ||
(event.key !== "ArrowUp" && event.key !== "ArrowDown") ||
event.altKey ||
event.ctrlKey ||
event.metaKey ||
event.nativeEvent.isComposing
) {
return false;
}
const history = recentComposerHistory(activeTab.messages);
const stored = historyNavigationRef.current;
const expectedValue =
stored.sessionId === activeTab.id && stored.cursor.index >= 0
? history[stored.cursor.index]
: stored.cursor.draft;
const cursor =
stored.sessionId === activeTab.id && expectedValue === activeTab.input
? stored.cursor
: { index: -1, draft: activeTab.input };
if (cursor.index < 0 && activeTab.input.length > 0) return false;
const step = stepComposerHistory(
activeTab.messages,
cursor,
event.key === "ArrowUp" ? "older" : "newer",
);
if (!step) return false;
event.preventDefault();
historyNavigationRef.current = { sessionId: activeTab.id, cursor: step.cursor };
updateTab(activeTab.id, (tab) => ({ ...tab, input: step.value }));
setMention(null);
resizeAfterCommit(step.value, step.value.length);
return true;
},
[activeTab, resizeAfterCommit, setMention, updateTab],
);

const handleComposerKeyDown = useCallback(
(event: KeyboardEvent<HTMLTextAreaElement>) => {
if (mention && handleMentionKey(event)) return;
if (handleComposerHistoryKey(event)) return;
// While a turn is running, Enter QUEUES rather than steers. Steering
// interrupts the agent's plan mid-flight, so it stays a deliberate act —
// the drawer's "Interrupt now" button, promoting an item in the queue
Expand All @@ -292,7 +347,15 @@ export function useComposerTextareaBehavior({
}
}
},
[abortTurn, activeTab, handleMentionKey, mention, queueMessage, running],
[
abortTurn,
activeTab,
handleComposerHistoryKey,
handleMentionKey,
mention,
queueMessage,
running,
],
);

return {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import {
messagesToResumeAfterAbort,
removePendingSteersClearedByAbort,
} from "./chat-pane-send-flow-model";

test("stop resumes the visible queue without duplicating the runtime copy", () => {
assert.deepEqual(
messagesToResumeAfterAbort(
[{ id: "queue-1", mode: "follow_up", text: "send this next", sent: true }],
{ steering: [], followUp: ["send this next"] },
),
["send this next"],
);
});

test("stop recovers runtime-only steering and follow-ups in delivery order", () => {
assert.deepEqual(
messagesToResumeAfterAbort([], {
steering: ["steer now"],
followUp: ["<browser_context>\ninternal\n</browser_context>\n\nfollow up after stop"],
}),
["steer now", "follow up after stop"],
);
});

test("stop replaces an undelivered optimistic steer instead of duplicating it", () => {
assert.deepEqual(
removePendingSteersClearedByAbort(
[
{ id: "user-1", role: "user", text: "already delivered" },
{
id: "user-2",
role: "user",
text: "steer now",
pending: true,
awaitingEcho: true,
},
],
{ steering: ["steer now"], followUp: [] },
).map((message) => message.id),
["user-1"],
);
});
Loading
Loading