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
Binary file added apps/mobile/assets/context-chip-icons/server.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions apps/mobile/scripts/sync-context-chip-icons.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ const lucideIcons = [
"message-circle",
"messages-square",
"mouse-pointer-click",
"server",
"terminal",
];

Expand Down
3 changes: 3 additions & 0 deletions apps/mobile/src/components/ComposerContextSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,9 @@ export function ComposerContextSheet(props: {
) : null}
</View>
) : null}
{record.kind === "environment" ? (
<ContextField label="Machine" value={record.label} />
) : null}
{record.kind === "skill" ? (
<View className="gap-3">
<ContextField label="Skill" value={record.name} />
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { offersLinkedEnvironments } from "@t3tools/client-runtime/link-request";
import { peerLinkHealth } from "@t3tools/client-runtime/state/peerLinks";
import { useFocusEffect } from "@react-navigation/native";
import { AuthAccessReadScope, type EnvironmentId } from "@t3tools/contracts";
Expand All @@ -18,7 +19,7 @@ export function LinkedEnvironmentsSection(props: {
}) {
const config = useEnvironmentServerConfig(props.environmentId);
const canRead = useEnvironmentScope(props.environmentId, AuthAccessReadScope);
const supported = canRead && config?.environment.capabilities.peerLinks === true;
const supported = canRead && offersLinkedEnvironments(config);
const links = useEnvironmentQuery(
supported ? peerLinkEnvironment.list({ environmentId: props.environmentId, input: {} }) : null,
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ vi.mock("../../state/session", () => ({
),
}));
vi.mock("../../state/server", () => ({ serverEnvironment: { updateSettings: {} } }));
vi.mock("../../state/environments", () => ({ useEnvironments: () => ({ environments: [] }) }));
vi.mock("../../state/use-atom-command", () => ({
useAtomCommand: () => mocks.updateSettings,
}));
Expand Down
45 changes: 45 additions & 0 deletions apps/mobile/src/features/settings/ThreadSettingsSections.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts";
import { supportsSharedSettingsSync } from "@t3tools/client-runtime/state/shared-settings";
import { AppText as Text } from "../../components/ui/AppText";
import { useEnvironments } from "../../state/environments";
import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
import { serverEnvironment } from "../../state/server";
import { useAtomCommand } from "../../state/use-atom-command";
import { SettingsSection } from "./components/SettingsSection";
import { SettingsSwitchRow } from "./components/SettingsSwitchRow";
import { SettingsScreen } from "./components/SettingsScreen";
Expand All @@ -31,6 +34,7 @@ export function SettingsLabsRouteScreen() {
contentContainerClassName="gap-6 px-5 pt-4"
contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }}
>
<BetaSettingsSection />
<LegacySettingsSection />
</ScrollView>
</SettingsScreen>
Expand Down Expand Up @@ -214,6 +218,47 @@ export function NewThreadSettingsSection() {
);
}

/** Per machine, since each machine's own setting decides whether it links and answers links. */
function BetaSettingsSection() {
const { environments } = useEnvironments();
const supported = environments.filter(
(environment) =>
environment.connection.phase === "connected" &&
environment.serverConfig?.environment.capabilities.peerLinks === true,
);
const writable = useEnvironmentsWithScope(supported, AuthSettingsWriteScope);
const updateSettings = useAtomCommand(serverEnvironment.updateSettings, {
label: "linked environments beta update",
reportFailure: true,
});
if (supported.length === 0) return null;
return (
<View className="gap-3">
<SettingsSection title="Beta: Linked environments">
{supported.map((environment) => (
<SettingsSwitchRow
key={environment.environmentId}
icon="link"
label={environment.label}
value={environment.serverConfig?.settings.linkedEnvironmentsEnabled === true}
disabled={!writable.has(environment.environmentId)}
onValueChange={(value) =>
void updateSettings({
environmentId: environment.environmentId,
input: { patch: { linkedEnvironmentsEnabled: value } },
})
}
/>
))}
</SettingsSection>
<Text className="px-2 text-sm text-foreground-muted">
Let agents on a machine work on your other machines, and theirs on it. Turn it on for each
machine. While it is off, nothing new starts over a link; tasks already running finish.
</Text>
</View>
);
}

function LegacySettingsSection() {
const savePreferences = useAtomSet(updateMobilePreferencesAtom);
const preferences = useAtomValue(mobilePreferencesAtom);
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/features/settings/settings-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ const SETTINGS_SEARCH_TERMS = {
"submodules",
"start from remote",
],
SettingsLabs: ["legacy", "plan mode", "beta"],
SettingsLabs: ["legacy", "plan mode", "beta", "linked environments", "link machine"],
SettingsEnvironmentMaintenance: ["updates", "provider updates", "restart", "manage environments"],
SettingsAbout: [
"version",
Expand Down
13 changes: 13 additions & 0 deletions apps/mobile/src/features/threads/ComposerCommandPopover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import {
type ProviderSkillSourceKind,
} from "@t3tools/client-runtime/providerSkills";
import type {
EnvironmentId,
EnvironmentMachineKind,
PullRequestContextMetadata,
ScopedThreadRef,
ServerProviderSkill,
Expand All @@ -14,6 +16,7 @@ import { Pressable, ScrollView, StyleSheet, View, type ViewStyle } from "react-n

import { SymbolView, type AppSymbolName } from "../../components/ui/AppSymbol";
import { AppText as Text } from "../../components/ui/AppText";
import { ENVIRONMENT_MACHINE_SYMBOLS } from "../../components/EnvironmentMachineSymbol";
import { GlassSurface } from "../../components/ui/GlassSurface";
import { PierreEntryIcon } from "../../components/PierreEntryIcon";
import { pullRequestStatus } from "../pull-requests/pullRequestStatus";
Expand All @@ -40,6 +43,14 @@ export type ComposerCommandItem =
readonly label: string;
readonly description: string;
}
| {
readonly id: string;
readonly type: "environment";
readonly environmentId: EnvironmentId;
readonly machine: EnvironmentMachineKind;
readonly label: string;
readonly description: string;
}
| {
readonly id: string;
readonly type: "slash-command";
Expand Down Expand Up @@ -110,6 +121,8 @@ function itemIcon(item: ComposerCommandItem): AppSymbolName | null {
return null;
case "thread":
return "text.bubble";
case "environment":
return ENVIRONMENT_MACHINE_SYMBOLS[item.machine];
}
}

Expand Down
8 changes: 8 additions & 0 deletions apps/mobile/src/features/threads/ThreadComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ import {
import { appAtomRegistry } from "../../state/atom-registry";
import type { ComposerDocumentAttachment } from "../../lib/composerContext";
import { useProject, useThreadShells } from "../../state/entities";
import { useMentionableEnvironments } from "../../state/environments";
import { scopeProjectRef } from "@t3tools/client-runtime/environment";

import { AppText as Text } from "../../components/ui/AppText";
Expand Down Expand Up @@ -99,6 +100,8 @@ import { ComposerCommandPopover } from "./ComposerCommandPopover";
import { ComposerPopoverAnchor } from "./ComposerPopoverAnchor";
import { ComposerQueuedEditAttachments } from "./ComposerQueuedEdit";
import { useComposerCommandMenu } from "./use-composer-command-menu";
import type { ComposerEnvironmentCandidate } from "@t3tools/client-runtime/composerThreadItems";
import { offersLinkedEnvironments } from "@t3tools/client-runtime/link-request";
import {
ComposerDictationCancelAction,
ComposerDictationDraftContent,
Expand All @@ -117,6 +120,7 @@ import {

export const COMPOSER_COLLAPSED_CHROME = 60;

const NO_MACHINES: ReadonlyArray<ComposerEnvironmentCandidate> = [];
const COLLAPSED_EDITOR_DEFAULT_INSET = 6;
// iOS puts line-height slack above glyphs. DM Sans's lowercase midline is
// descender (0.31em) + half x-height (0.504em) above the line bottom.
Expand Down Expand Up @@ -523,11 +527,15 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps
return report !== null;
};

const mentionableEnvironments = useMentionableEnvironments();
const composerMenu = useComposerCommandMenu({
draftMessage: draftMessage,
ownerKey: composerDraftKey,
environmentId: props.environmentId,
threadShells: useThreadShells(),
environments: offersLinkedEnvironments(props.serverConfig)
? mentionableEnvironments
: NO_MACHINES,
currentThreadId: props.selectedThread.id,
projectCwd: props.projectCwd,
pullRequestProjectId: props.serverConfig?.environment.capabilities.pullRequests
Expand Down
45 changes: 43 additions & 2 deletions apps/mobile/src/features/threads/use-composer-command-menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,22 @@ import type {
ThreadId,
ThreadPullRequestLink,
} from "@t3tools/contracts";
import { matchComposerThreadItems } from "@t3tools/client-runtime/composerThreadItems";
import {
type ComposerEnvironmentCandidate,
matchComposerEnvironmentItems,
matchComposerThreadItems,
} from "@t3tools/client-runtime/composerThreadItems";
import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models";

const EMPTY_THREAD_SHELLS: ReadonlyArray<EnvironmentThreadShell> = [];
const EMPTY_ENVIRONMENTS: ReadonlyArray<ComposerEnvironmentCandidate> = [];
import { COMPOSER_CONTEXT_MAX_RECORDS } from "@t3tools/contracts";
import { formatComposerContextReference } from "@t3tools/shared/composerContextReferences";
import { pullRequestComposerContext, threadComposerContext } from "../../lib/composerContext";
import {
environmentComposerContext,
pullRequestComposerContext,
threadComposerContext,
} from "../../lib/composerContext";
import { uuidv4 } from "../../lib/uuid";
import {
getComposerDraftSnapshot,
Expand Down Expand Up @@ -201,6 +210,7 @@ export function useComposerCommandMenu({
ownerKey,
environmentId,
threadShells = EMPTY_THREAD_SHELLS,
environments = EMPTY_ENVIRONMENTS,
currentThreadId = null,
projectCwd,
pullRequestProjectId = null,
Expand All @@ -219,6 +229,7 @@ export function useComposerCommandMenu({
readonly ownerKey: string | null;
readonly environmentId: EnvironmentId | null;
readonly threadShells?: ReadonlyArray<EnvironmentThreadShell>;
readonly environments?: ReadonlyArray<ComposerEnvironmentCandidate>;
readonly currentThreadId?: ThreadId | null;
readonly projectCwd: string | null;
readonly pullRequestProjectId?: ProjectId | null;
Expand Down Expand Up @@ -452,8 +463,12 @@ export function useComposerCommandMenu({
now,
})
: [];
const environmentItems = environmentId
? matchComposerEnvironmentItems({ environments, environmentId, query: trigger.query })
: [];
return [
...threadItems,
...environmentItems,
...pathSearch.entries.map((entry) => {
const parts = entry.path.split("/");
return {
Expand All @@ -473,6 +488,32 @@ export function useComposerCommandMenu({

const onSelect = (item: ComposerCommandItem) => {
if (!trigger) return;
if (item.type === "environment") {
if (!ownerKey || trigger.kind !== "path") return;
const record = environmentComposerContext(item.environmentId, item.label);
const existing = getComposerDraftSnapshot(ownerKey).context?.records ?? [];
const alreadyAttached = existing.some((entry) => entry.contextId === record.contextId);
if (!alreadyAttached && existing.length >= COMPOSER_CONTEXT_MAX_RECORDS) {
Dialog.alert("Too many context items", "Remove some context from the draft and try again.");
return;
}
const result = replaceTextRange(
draftMessage,
trigger.rangeStart,
trigger.rangeEnd,
`${formatComposerContextReference(record)} `,
);
onChangeDraftMessage(result.text);
if (!alreadyAttached) {
const draft = getComposerDraftSnapshot(ownerKey);
setComposerDraftContext(ownerKey, {
version: 1,
records: [...(draft.context?.records ?? []), record],
});
}
setSelection({ start: result.cursor, end: result.cursor });
return;
}
if (item.type === "thread") {
if (!ownerKey || trigger.kind !== "path") return;
const shell = threadShells.find(
Expand Down
7 changes: 7 additions & 0 deletions apps/mobile/src/lib/composerChipPresentation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const CHIP_ACCENTS = [
"pull-request",
"skill",
"thread",
"environment",
].map((kind) => contextChipPresentation(kind)),
...[
{ state: "open", isDraft: false },
Expand Down Expand Up @@ -107,3 +108,9 @@ describe("contextChipAccent", () => {
expect(contextChipAccent("#009f6e", theme, theme["--color-md-body"])).toBe("#009f6e");
});
});

describe("contextChipPresentation", () => {
it("draws a mentioned machine as a machine, not the generic file chip", () => {
expect(contextChipPresentation("environment")).toEqual({ accent: "#3f9a4f", icon: "server" });
});
});
2 changes: 2 additions & 0 deletions apps/mobile/src/lib/composerChipPresentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ const CONTEXT_CHIP_PRESENTATIONS = {
accent: "#009c96",
icon: "messages-square",
},
// A machine: the record carries only its identity, so every machine wears one glyph.
environment: { accent: "#3f9a4f", icon: "server" },
} as const satisfies Record<string, ContextChipPresentation>;

const CHIP_CONTRAST = 5;
Expand Down
16 changes: 16 additions & 0 deletions apps/mobile/src/lib/composerContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import {
type ScopedThreadRef,
type ServerProviderSkill,
type ThreadContextRecord,
type EnvironmentContextRecord,
type EnvironmentId,
} from "@t3tools/contracts";
import * as Schema from "effect/Schema";
import {
Expand Down Expand Up @@ -115,6 +117,20 @@ export function threadComposerContext(ref: ScopedThreadRef, title: string): Thre
};
}

/** Same identity as web: one record per machine, so mentioning it again reuses the chip. */
export function environmentComposerContext(
environmentId: EnvironmentId,
label: string,
): EnvironmentContextRecord {
return {
version: 1,
kind: "environment",
contextId: ComposerContextId.make(`environment_${environmentId}`),
label: sanitizeComposerContextLabel(label, "environment"),
environmentId,
};
}

export function pullRequestComposerContext(
pullRequest: PullRequestContextMetadata,
id: string,
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/lib/contextChipIcons.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export const CONTEXT_CHIP_ICON_SOURCES = {
"message-circle": require("../../assets/context-chip-icons/message-circle.png"),
"messages-square": require("../../assets/context-chip-icons/messages-square.png"),
"mouse-pointer-click": require("../../assets/context-chip-icons/mouse-pointer-click.png"),
server: require("../../assets/context-chip-icons/server.png"),
skill: require("../../assets/context-chip-icons/skill.png"),
terminal: require("../../assets/context-chip-icons/terminal.png"),
} as const satisfies Readonly<Record<string, ImageSourcePropType>>;
17 changes: 16 additions & 1 deletion apps/mobile/src/state/environments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import {
hasRelayRoute,
type EnvironmentPresentation as BaseEnvironmentPresentation,
} from "@t3tools/client-runtime/connection";
import type { EnvironmentId } from "@t3tools/contracts";
import type { ComposerEnvironmentCandidate } from "@t3tools/client-runtime/composerThreadItems";
import { type EnvironmentId, resolveEnvironmentMachineKind } from "@t3tools/contracts";
import { useMemo } from "react";

import { environmentCatalog } from "../connection/catalog";
Expand Down Expand Up @@ -55,3 +56,17 @@ export function useEnvironments() {
export function useEnvironmentConnectionState(environmentId: EnvironmentId) {
return useEnvironmentQuery(environmentCatalog.stateAtom(environmentId));
}

/** The user's machines as `@` mention candidates, drawn with their machine kind. */
export function useMentionableEnvironments(): ReadonlyArray<ComposerEnvironmentCandidate> {
const { environments } = useEnvironments();
return useMemo(
() =>
environments.map((environment) => ({
environmentId: environment.environmentId,
label: environment.label,
machine: resolveEnvironmentMachineKind(environment.serverConfig),
})),
[environments],
);
}
8 changes: 7 additions & 1 deletion apps/server/src/auth/McpOAuth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import * as Schema from "effect/Schema";

import * as ServerConfig from "../config.ts";
import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
import * as ServerSettings from "../serverSettings.ts";
import * as Sqlite from "../persistence/Sqlite.ts";
import * as EnvironmentAuth from "./EnvironmentAuth.ts";
import * as McpOAuth from "./McpOAuth.ts";
Expand All @@ -46,7 +47,12 @@ const makeLayerRoutes = (capture: (auth: EnvironmentAuth.EnvironmentAuth["Servic
),
HttpApiBuilder.layer(AuthTestApi).pipe(
Layer.provide(AuthHttp.layer),
Layer.provide(McpOAuthHttp.layer.pipe(Layer.provide(McpOAuth.layer))),
Layer.provide(
McpOAuthHttp.layer.pipe(
Layer.provide(McpOAuth.layer),
Layer.provide(ServerSettings.layerTest()),
),
),
Layer.provide(AuthHttp.layerAuthenticatedAuth),
),
).pipe(
Expand Down
Loading
Loading