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
11 changes: 11 additions & 0 deletions apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const CONTEXT_CHIP_PRESENTATIONS = {
"pull-request": { accent: "#7079e4", symbol: "git-pull-request" },
skill: { accent: "#b261be", symbol: "cube" },
"linear-issue": { accent: "#617de6", symbol: "ticket" },
"github-issue": { accent: "#009f6e", symbol: "smallcircle.filled.circle" },
repository: { accent: "#009c9c", symbol: "folder" },
} as const;

Expand Down Expand Up @@ -46,13 +47,20 @@ const PULL_REQUEST_CHIP_PRESENTATIONS = {
closed: { accent: "#d55665", symbol: "git-pull-request" },
} as const;

/** A GitHub issue chip is green while open and purple once closed, the way GitHub colours it. */
const CLOSED_GITHUB_ISSUE_CHIP_PRESENTATION = {
accent: "#8a70dd",
symbol: "smallcircle.filled.circle",
} as const;

export function contextChipPresentation(
kind: string,
record?: {
readonly kind?: string;
readonly name?: string;
readonly mimeType?: string;
readonly sectionId?: string;
readonly state?: string;
readonly pullRequest?: {
readonly state?: string;
readonly isDraft?: boolean;
Expand Down Expand Up @@ -84,6 +92,9 @@ export function contextChipPresentation(
return PULL_REQUEST_CHIP_PRESENTATIONS[state as keyof typeof PULL_REQUEST_CHIP_PRESENTATIONS];
}
}
if (presentationKind === "github-issue" && record?.state === "closed") {
return CLOSED_GITHUB_ISSUE_CHIP_PRESENTATION;
}
return Object.hasOwn(CONTEXT_CHIP_PRESENTATIONS, presentationKind)
? CONTEXT_CHIP_PRESENTATIONS[presentationKind as keyof typeof CONTEXT_CHIP_PRESENTATIONS]
: CONTEXT_CHIP_PRESENTATIONS.file;
Expand Down
10 changes: 10 additions & 0 deletions apps/mobile/src/components/ComposerAttachmentButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ import { ControlPillMenu } from "./ControlPill";
const PHOTOS_ACTION: MenuAction = { id: "photos", title: "Photo Library", image: "photo" };
const FILES_ACTION: MenuAction = { id: "files", title: "Choose Files", image: "folder" };
const LINEAR_ACTION: MenuAction = { id: "linear", title: "Linear issue", image: "ticket" };
const GITHUB_ISSUE_ACTION: MenuAction = {
id: "github-issue",
title: "GitHub issue",
image: "smallcircle.filled.circle",
};
const REPOSITORY_ACTION: MenuAction = {
id: "repository",
title: "Repository",
Expand All @@ -21,6 +26,8 @@ export function ComposerAttachmentButton(props: {
readonly onPickFiles: () => Promise<void>;
/** Omit to hide the Linear action. See `useLinearIssuePicker`. */
readonly onPickLinearIssue?: () => void;
/** Omit to hide the GitHub issue action. See `useGitHubIssuePicker`. */
readonly onPickGitHubIssue?: () => void;
/** Omit to hide the repository action. See `useRepositoryPicker`. */
readonly onPickRepository?: () => void;
}) {
Expand All @@ -29,6 +36,7 @@ export function ComposerAttachmentButton(props: {
PHOTOS_ACTION,
...(props.supportsFiles ? [FILES_ACTION] : []),
...(props.onPickLinearIssue ? [LINEAR_ACTION] : []),
...(props.onPickGitHubIssue ? [GITHUB_ISSUE_ACTION] : []),
...(props.onPickRepository ? [REPOSITORY_ACTION] : []),
];
const usesMenu = !props.disabled && actions.length > 1;
Expand Down Expand Up @@ -68,6 +76,8 @@ export function ComposerAttachmentButton(props: {
void props.onPickFiles();
} else if (nativeEvent.event === "linear") {
props.onPickLinearIssue?.();
} else if (nativeEvent.event === "github-issue") {
props.onPickGitHubIssue?.();
} else if (nativeEvent.event === "repository") {
props.onPickRepository?.();
}
Expand Down
30 changes: 30 additions & 0 deletions apps/mobile/src/components/ComposerContextSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,36 @@ export function ComposerContextSheet(props: {
</Text>
</View>
) : null}
{record.kind === "github-issue" ? (
<View className="gap-3">
<View className="gap-1">
<Text selectable className="text-lg font-t3-semibold text-foreground">
{record.title}
</Text>
<Text className="text-sm text-foreground-muted">
{record.repository}#{record.number} ·{" "}
{record.state === "open" ? "Open" : "Closed"}
</Text>
</View>
{/^https?:\/\//i.test(record.url) ? (
<Pressable
accessibilityRole="link"
onPress={() => {
void tryOpenExternalUrl(record.url, "github-issue").then((opened) => {
if (!opened) Alert.alert("Could not open GitHub", "Try again later.");
});
}}
className="rounded-xl bg-subtle p-4"
>
<Text className="text-foreground">Open on GitHub</Text>
</Pressable>
) : null}
{/* Plain text like the other captured context here: exactly what the agent received. */}
<Text selectable className="text-sm text-foreground">
{record.markdown}
</Text>
</View>
) : null}
{record.kind === "thread-tab" ? (
<View className="gap-3">
<ContextField label="Chat" value={record.title} />
Expand Down
278 changes: 278 additions & 0 deletions apps/mobile/src/components/GitHubIssuePickerSheet.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,278 @@
import type {
EnvironmentId,
GitHubIssueStateFilter,
GitHubIssueSummary,
OrchestrationMessageContext,
ProjectId,
} from "@t3tools/contracts";
import {
gitHubIssueContextRecord,
gitHubIssueLabel,
threadsForGitHubIssue,
} from "@t3tools/client-runtime/state/github-issues";
import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
import { formatComposerContextReference } from "@t3tools/shared/composerContextReferences";
import { useAtomValue } from "@effect/atom-react";
import * as Cause from "effect/Cause";
import * as Option from "effect/Option";
import { AsyncResult } from "effect/unstable/reactivity";
import { StackActions, useNavigation } from "@react-navigation/native";
import { useState, type ReactNode } from "react";
import { ActivityIndicator, Alert, FlatList, Pressable, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";

import { useThreadShells } from "../state/entities";
import { gitHubIssueEnvironment } from "../state/githubIssues";
import { useDebouncedValue } from "../state/queries";
import { useEnvironmentQuery } from "../state/query";
import { useAtomCommand } from "../state/use-atom-command";
import {
captureComposerDraftInsertion,
insertComposerDraftContext,
type ComposerDraftInsertion,
} from "../state/use-composer-drafts";
import { confirmOpenExistingThread } from "./confirmOpenExistingThread";
import { AppText as Text, AppTextInput } from "./AppText";
import { PickerSheet } from "./PickerSheet";
import { SegmentedControl } from "./SegmentedControl";

/** Each search runs `gh` on the host, so typing settles before it queries. */
const SEARCH_DEBOUNCE_MS = 300;

const STATE_OPTIONS = [
{ value: "open", label: "Open" },
{ value: "closed", label: "Closed" },
{ value: "all", label: "All" },
] as const satisfies ReadonlyArray<{ value: GitHubIssueStateFilter; label: string }>;

/** GitHub's failures carry a reason; the host's `gh` setup deserves a clearer hint than its detail. */
function gitHubIssueErrorMessage(error: unknown, fallback: string): string {
if (typeof error === "object" && error !== null && "reason" in error) {
if (error.reason === "rate-limited") return "GitHub is busy. Try again shortly.";
if (error.reason === "unavailable") {
return "The GitHub CLI (gh) is not installed on the computer running T3 Code.";
}
if (error.reason === "not-authenticated") {
return "The GitHub CLI is not signed in. Run `gh auth login` on the computer running T3 Code.";
}
}
return error instanceof Error && error.message.trim() ? error.message : fallback;
}

/**
* Attach inserts the issue into a composer draft. `cwd` is the checkout whose repository lists
* issues. With `startFrom` it starts a new thread from the issue: it first offers threads already
* linked to it, and the thread the draft creates is linked once it sends.
*/
export interface GitHubIssuePickerTarget {
readonly environmentId: EnvironmentId;
readonly draftKey: string;
readonly cwd: string;
readonly startFrom?: { readonly projectId: ProjectId };
}

type OpenedGitHubIssuePicker = GitHubIssuePickerTarget & {
readonly insertion: ComposerDraftInsertion;
};

/** Issues picked to start a draft's thread from, by draft key, until the draft sends. */
const startFromIssueUrls = new Map<string, string>();

/**
* The issue URL the draft was started from, if its chip is still in the draft. Clears it: the
* caller links the thread the draft just created.
*/
export function takeStartFromGitHubIssue(
draftKey: string,
context: OrchestrationMessageContext | undefined,
): string | null {
const url = startFromIssueUrls.get(draftKey);
startFromIssueUrls.delete(draftKey);
if (url === undefined) return null;
const attached = context?.records.some(
(record) => record.kind === "github-issue" && "url" in record && record.url === url,
);
return attached ? url : null;
}

/**
* A GitHub issue picker. The owner renders `sheet` somewhere that outlives composer focus:
* opening the sheet blurs the editor, which can unmount a focus-dependent toolbar.
*/
export function useGitHubIssuePicker(target: GitHubIssuePickerTarget | null): {
readonly open: () => void;
readonly sheet: ReactNode;
} {
const [opened, setOpened] = useState<OpenedGitHubIssuePicker | null>(null);
return {
open: () => {
if (target) {
setOpened({ ...target, insertion: captureComposerDraftInsertion(target.draftKey) });
}
},
sheet: opened ? <GitHubIssuePickerSheet {...opened} onClose={() => setOpened(null)} /> : null,
};
}

/** Attaches a GitHub issue at the caret the composer had when the sheet opened. */
function GitHubIssuePickerSheet(props: OpenedGitHubIssuePicker & { readonly onClose: () => void }) {
const insets = useSafeAreaInsets();
const navigation = useNavigation();
const { startFrom } = props;
const [query, setQuery] = useState("");
const [state, setState] = useState<GitHubIssueStateFilter>("open");
const trimmed = query.trim();
const settled = useDebouncedValue(trimmed, SEARCH_DEBOUNCE_MS);
const result = useAtomValue(
gitHubIssueEnvironment.issues({
environmentId: props.environmentId,
input: { cwd: props.cwd, state, ...(settled ? { query: settled } : {}) },
}),
);
const threadLinks = useEnvironmentQuery(
gitHubIssueEnvironment.threadLinks({ environmentId: props.environmentId, input: {} }),
).data;
// Starting from an issue offers the project's threads; attaching only marks issues in use.
const candidateThreads = useThreadShells().filter(
(thread) =>
thread.environmentId === props.environmentId &&
(!startFrom || thread.projectId === startFrom.projectId),
);
const getIssue = useAtomCommand(gitHubIssueEnvironment.getIssue, {
label: "github issue fetch",
reportFailure: false,
});
const [attaching, setAttaching] = useState<string | null>(null);
const issues = Option.getOrNull(AsyncResult.value(result))?.issues ?? [];
const pending = settled !== trimmed || result.waiting;
const error =
result._tag === "Failure"
? gitHubIssueErrorMessage(Cause.squash(result.cause), "Could not load GitHub issues.")
: null;

const pick = async (issue: GitHubIssueSummary) => {
if (attaching) return;
if (startFrom) {
const choice = await confirmOpenExistingThread(
gitHubIssueLabel(issue),
threadsForGitHubIssue(candidateThreads, threadLinks, issue.url),
);
if (choice === "cancel") return;
if (choice !== "start") {
props.onClose();
navigation.dispatch(
StackActions.replace("Thread", {
environmentId: String(choice.environmentId),
threadId: String(choice.id),
}),
);
return;
}
}
await attach(issue);
};

const attach = async (issue: GitHubIssueSummary) => {
setAttaching(issue.url);
try {
const fetched = await getIssue({
environmentId: props.environmentId,
input: { url: issue.url },
});
if (fetched._tag === "Failure") {
Alert.alert(
"Could not attach issue",
gitHubIssueErrorMessage(squashAtomCommandFailure(fetched), "Try again."),
);
return;
}
const record = gitHubIssueContextRecord(fetched.value);
if (
!insertComposerDraftContext(
props.draftKey,
{
text: formatComposerContextReference(record),
context: { version: 1, records: [record] },
},
props.insertion,
)
) {
Alert.alert("Too many context items", "Remove some context from the draft and try again.");
return;
}
if (startFrom) startFromIssueUrls.set(props.draftKey, issue.url);
props.onClose();
} finally {
setAttaching(null);
}
};

return (
<PickerSheet title={startFrom ? "Start from issue" : "GitHub issue"} onClose={props.onClose}>
<View className="gap-3 px-4 pb-3">
<AppTextInput
accessibilityLabel="Search GitHub issues"
autoFocus
autoCapitalize="none"
autoCorrect={false}
clearButtonMode="while-editing"
placeholder="Search, or paste #123 or an issue link"
returnKeyType="search"
value={query}
onChangeText={setQuery}
/>
<SegmentedControl
options={STATE_OPTIONS}
selected={state}
onSelect={setState}
size="compact"
/>
</View>
<FlatList
data={issues}
keyExtractor={(issue) => issue.url}
keyboardShouldPersistTaps="handled"
contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 16) }}
ListEmptyComponent={
<View className="items-center p-6">
{pending ? (
<ActivityIndicator />
) : (
<Text
selectable={error !== null}
className={
error ? "text-center text-danger-foreground" : "text-center text-foreground-muted"
}
>
{error ?? (trimmed ? "No matching issues." : "No issues.")}
</Text>
)}
</View>
}
renderItem={({ item }) => {
const inUse = threadsForGitHubIssue(candidateThreads, threadLinks, item.url).length > 0;
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={`${gitHubIssueLabel(item)} ${item.title}, ${item.state}${inUse ? ", in use" : ""}`}
disabled={attaching !== null}
onPress={() => void pick(item)}
className="flex-row items-center gap-3 px-4 py-3 active:bg-subtle disabled:opacity-60"
>
<View className="min-w-0 flex-1 gap-0.5">
<Text className="text-base text-foreground" numberOfLines={2}>
{item.title}
</Text>
<Text className="text-sm text-foreground-muted" numberOfLines={1}>
{gitHubIssueLabel(item)} · {item.state === "open" ? "Open" : "Closed"}
{inUse ? " · In use" : ""}
</Text>
</View>
{attaching === item.url ? <ActivityIndicator /> : null}
</Pressable>
);
}}
/>
</PickerSheet>
);
}
Loading