Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
e027bef
feat(integrations): add Google Docs and Sheets context
adampeterhiggins Oct 24, 2026
012a34a
fix(web): register Google context in sent messages
adampeterhiggins Oct 25, 2026
0de4056
feat(integrations): extend Google to Drive files and Calendar events
adampeterhiggins Oct 25, 2026
2833fb6
fix(web): label the Google attach menu item just Google
adampeterhiggins Oct 25, 2026
3720bb6
fix(web): size the Google picker row icons
adampeterhiggins Oct 25, 2026
de62931
fix(web): show the Google icon on loading Google link chips
adampeterhiggins Oct 25, 2026
ceaf993
feat(web): columns, filters and sort in the Google picker
adampeterhiggins Oct 25, 2026
14dbb4f
fix(server): Google previews survive hidden comments and small sheet …
adampeterhiggins Oct 25, 2026
3deda75
feat(web): open link hover previews at once with a loading placeholder
adampeterhiggins Oct 25, 2026
bf3af3a
feat(web): calendar-style week view for the Google Calendar picker
adampeterhiggins Oct 25, 2026
ff2006d
fix(server): read only the Google calendars the user has ticked
adampeterhiggins Oct 25, 2026
c4e9a6d
feat(google): choose which calendars the picker shows
adampeterhiggins Oct 25, 2026
ccbe070
feat(web): show each calendar the Google picker reads as a pill
adampeterhiggins Oct 25, 2026
29ac652
feat(google): find colleagues' calendars by name
adampeterhiggins Oct 25, 2026
4542fe9
fix(web): searching people in the calendar filter no longer crashes
adampeterhiggins Oct 25, 2026
e167744
feat(mcp): let agents list Google calendars and find colleagues
adampeterhiggins Oct 25, 2026
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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/mobile/assets/context-chip-icons/google-doc.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
17 changes: 15 additions & 2 deletions apps/mobile/scripts/sync-context-chip-icons.mjs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { INTEGRATION_ICONS } from "../../../packages/shared/src/brandMarks.ts";
import * as NodeFS from "node:fs";
import * as NodeModule from "node:module";
import * as NodePath from "node:path";
Expand Down Expand Up @@ -92,8 +93,20 @@ for (const name of lucideIcons) {
await renderIcon(name, await lucideSvg(name));
}
await renderIcon("skill", skillSvg);

const tokens = [...lucideIcons, "skill"].sort();
const googleBrands = [
["google-doc", "googleDocs"],
["google-sheet", "googleSheets"],
["google-drive", "googleDrive"],
["google-calendar", "googleCalendar"],
];
for (const [token, brand] of googleBrands) {
const icon = INTEGRATION_ICONS[brand];
await renderIcon(
token,
`<svg viewBox="${icon.viewBox}" fill="currentColor"><path d="${icon.path}"/></svg>`,
);
}
const tokens = [...googleBrands.map(([token]) => token), ...lucideIcons, "skill"].sort();
const generatedSource = `import type { ImageSourcePropType } from "react-native";\n\nexport const CONTEXT_CHIP_ICON_SOURCES = {\n${tokens
.map(
(token) =>
Expand Down
26 changes: 26 additions & 0 deletions apps/mobile/src/components/ComposerContextSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -555,6 +555,32 @@ export function ComposerContextSheet(props: {
</Text>
</View>
) : null}
{record.kind === "google-file" ? (
<View className="gap-3">
<View className="gap-1">
<Text selectable className="text-lg font-t3-semibold text-foreground">
{record.title}
</Text>
</View>
{/^https?:\/\//i.test(record.url) ? (
<Pressable
accessibilityRole="link"
onPress={() => {
void tryOpenExternalUrl(record.url, "markdown-link").then((opened) => {
if (!opened) Dialog.alert("Could not open Google", "Try again later.");
});
}}
className="rounded-xl bg-subtle p-4"
>
<Text className="text-foreground">Open in Google</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 === "granola-note" ? (
<View className="gap-3">
<View className="gap-1">
Expand Down
294 changes: 294 additions & 0 deletions apps/mobile/src/components/GoogleFilePickerSheet.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,294 @@
import type { EnvironmentId, GoogleFileSummary, ThreadId } from "@t3tools/contracts";
import { useAtomValue } from "@effect/atom-react";
import { googleFileContextRecord } from "@t3tools/client-runtime/state/google";
import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
import { formatComposerContextReference } from "@t3tools/shared/composerContextReferences";
import * as Cause from "effect/Cause";
import * as Option from "effect/Option";
import { AsyncResult } from "effect/reactivity";
import { useState, type ReactNode } from "react";
import { ActivityIndicator, Alert, FlatList, Pressable, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";

import { relativeTime } from "../lib/time";
import { useEnvironmentServerConfig } from "../state/entities";
import { googleEnvironment } from "../state/google";
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 { AppText as Text, AppTextInput } from "./ui/AppText";
import { PickerSheet } from "./PickerSheet";

/** Google allows search request quotas, so typing settles longer before it queries. */
const SEARCH_DEBOUNCE_MS = 400;

/** Google's failures carry a reason; a rate limit deserves a clearer hint than its detail. */
function googleErrorMessage(error: unknown, fallback: string): string {
if (typeof error === "object" && error !== null && "reason" in error) {
if (error.reason === "rate-limited") return "Google is busy. Try again shortly.";
if (error.reason === "not-connected" || error.reason === "revoked") {
return "Google is not connected. Connect it from Settings > Integrations on desktop or web.";
}
if (error.reason === "not-found")
return "Check that the connected Google account can open this document.";
}
return error instanceof Error && error.message.trim() ? error.message : fallback;
}

/** Attach inserts the page into a composer draft; link links it to the thread's tab group. */
export type GoogleFilePickerTarget =
| { readonly mode?: "attach"; readonly environmentId: EnvironmentId; readonly draftKey: string }
| { readonly mode: "link"; readonly environmentId: EnvironmentId; readonly threadId: ThreadId };

type OpenedGoogleFilePicker =
| {
readonly mode: "attach";
readonly environmentId: EnvironmentId;
readonly draftKey: string;
readonly insertion: ComposerDraftInsertion;
}
| { readonly mode: "link"; readonly environmentId: EnvironmentId; readonly threadId: ThreadId };

/**
* A Google file or event 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 useGoogleFilePicker(target: GoogleFilePickerTarget | null): {
readonly open: (() => void) | undefined;
readonly sheet: ReactNode;
} {
const [opened, setOpened] = useState<OpenedGoogleFilePicker | null>(null);
const serverConfig = useEnvironmentServerConfig(target?.environmentId ?? null);
const enabled = serverConfig?.settings.enableGoogleIntegration === true;
const openedServerConfig = useEnvironmentServerConfig(opened?.environmentId ?? null);
return {
open:
target && enabled
? () =>
setOpened(
target.mode === "link"
? target
: {
...target,
mode: "attach",
insertion: captureComposerDraftInsertion(target.draftKey),
},
)
: undefined,
sheet:
opened && openedServerConfig?.settings.enableGoogleIntegration ? (
<GoogleFilePickerSheet {...opened} onClose={() => setOpened(null)} />
) : null,
};
}

/**
* Attaches a Google file or event at the caret the composer had when the sheet opened, or links it to the
* thread's tab group. Mobile has no Google settings, so an unconnected environment points at
* desktop or web.
*/
function GoogleFilePickerSheet(props: OpenedGoogleFilePicker & { readonly onClose: () => void }) {
const connection = useEnvironmentQuery(
googleEnvironment.connection({ environmentId: props.environmentId, input: {} }),
);
const phase = connection.data?.phase;
return (
<PickerSheet
title={props.mode === "link" ? "Link Google file or event" : "Google file or event"}
onClose={props.onClose}
>
{phase === "connected" ? (
<GoogleFileSearch {...props} />
) : (
<View className="flex-1 items-center justify-center p-6">
{phase === undefined && !connection.error ? (
<ActivityIndicator />
) : (
<Text className="text-center text-foreground-muted">
{connection.error ??
"Google is not connected on this environment. Connect it from Settings > Integrations on desktop or web."}
</Text>
)}
</View>
)}
</PickerSheet>
);
}

function GoogleFileSearch(props: OpenedGoogleFilePicker & { readonly onClose: () => void }) {
const [query, setQuery] = useState("");
const trimmed = query.trim();
const settled = useDebouncedValue(trimmed, SEARCH_DEBOUNCE_MS);
const getFile = useAtomCommand(googleEnvironment.getFile, {
label: "google page fetch",
reportFailure: false,
});
const linkThread = useAtomCommand(googleEnvironment.linkThread, {
label: "google thread link",
reportFailure: false,
});
const [attaching, setAttaching] = useState<string | null>(null);

const link = async (threadId: ThreadId, page: GoogleFileSummary) => {
const result = await linkThread({
environmentId: props.environmentId,
input: { threadId, fileId: page.url },
});
if (result._tag === "Failure") {
Alert.alert(
"Could not link Google file or event",
googleErrorMessage(squashAtomCommandFailure(result), "Try again."),
);
return;
}
props.onClose();
};

const attach = async (page: GoogleFileSummary) => {
if (attaching) return;
setAttaching(page.url);
try {
if (props.mode === "link") {
await link(props.threadId, page);
return;
}
const fetched = await getFile({
environmentId: props.environmentId,
input: { id: page.url },
});
if (fetched._tag === "Failure") {
Alert.alert(
"Could not attach Google file or event",
googleErrorMessage(squashAtomCommandFailure(fetched), "Try again."),
);
return;
}
const record = googleFileContextRecord(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;
}
props.onClose();
} finally {
setAttaching(null);
}
};

return (
<View className="flex-1">
<View className="px-4 pb-3">
<AppTextInput
accessibilityLabel="Search Google files"
autoFocus
autoCapitalize="none"
autoCorrect={false}
clearButtonMode="while-editing"
placeholder="Search Google"
returnKeyType="search"
value={query}
onChangeText={setQuery}
/>
</View>
{settled ? (
<GoogleFileResults
environmentId={props.environmentId}
query={settled}
pendingInput={settled !== trimmed}
attaching={attaching}
onPick={(page) => void attach(page)}
/>
) : (
<View className="items-center p-6">
{trimmed ? (
<ActivityIndicator />
) : (
<Text className="text-center text-foreground-muted">
Search files and events by title or paste a Google link.
</Text>
)}
</View>
)}
</View>
);
}

/** Mounted only for a settled, non-empty query, so an empty box never spends a search. */
function GoogleFileResults(props: {
readonly environmentId: EnvironmentId;
readonly query: string;
readonly pendingInput: boolean;
readonly attaching: string | null;
readonly onPick: (page: GoogleFileSummary) => void;
}) {
const insets = useSafeAreaInsets();
const result = useAtomValue(
googleEnvironment.files({
environmentId: props.environmentId,
input: { query: props.query, source: "all" },
}),
);
const files = Option.getOrNull(AsyncResult.value(result))?.files ?? [];
const pending = props.pendingInput || result.waiting;
const error =
result._tag === "Failure"
? googleErrorMessage(Cause.squash(result.cause), "Could not search Google.")
: null;
return (
<FlatList
data={files}
keyExtractor={(page) => page.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 ?? "No matching files."}
</Text>
)}
</View>
}
renderItem={({ item }) => (
<Pressable
accessibilityRole="button"
accessibilityLabel={item.title}
disabled={props.attaching !== null}
onPress={() => props.onPick(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-sm text-foreground-muted" numberOfLines={1}>
<Text className="text-sm font-t3-semibold text-foreground">{item.title}</Text>
{` · ${relativeTime(item.updatedAt)}`}
</Text>
<Text className="text-base text-foreground" numberOfLines={2}>
{item.url}
</Text>
</View>
{props.attaching === item.url ? <ActivityIndicator /> : null}
</Pressable>
)}
/>
);
}
9 changes: 9 additions & 0 deletions apps/mobile/src/features/threads/ComposerAttachmentButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ const GITHUB_ISSUE_ACTION: MenuAction = {
image: "smallcircle.filled.circle",
};
const NOTION_ACTION: MenuAction = { id: "notion", title: "Notion page", image: "doc.text" };
const GOOGLE_ACTION: MenuAction = {
id: "google",
title: "Google file or event",
image: "doc.text",
};
const GRANOLA_ACTION: MenuAction = { id: "granola", title: "Granola note", image: "waveform" };
const SLACK_ACTION: MenuAction = { id: "slack", title: "Slack message", image: "number" };
const DISCORD_ACTION: MenuAction = {
Expand Down Expand Up @@ -44,6 +49,7 @@ export function ComposerAttachmentButton(props: {
readonly onPickGitHubIssue?: () => void;
/** Omit to hide the Notion action. See `useNotionPagePicker`. */
readonly onPickNotionPage?: () => void;
readonly onPickGoogleFile?: () => void;
/** Omit to hide the Granola action. See `useGranolaNotePicker`. */
readonly onPickGranolaNote?: () => void;
/** Omit to hide the Slack action. See `useSlackMessagePicker`. */
Expand All @@ -62,6 +68,7 @@ export function ComposerAttachmentButton(props: {
...(props.onPickLinearIssue ? [LINEAR_ACTION] : []),
...(props.onPickGitHubIssue ? [GITHUB_ISSUE_ACTION] : []),
...(props.onPickNotionPage ? [NOTION_ACTION] : []),
...(props.onPickGoogleFile ? [GOOGLE_ACTION] : []),
...(props.onPickGranolaNote ? [GRANOLA_ACTION] : []),
...(props.onPickSlackMessage ? [SLACK_ACTION] : []),
...(props.onPickDiscordMessage ? [DISCORD_ACTION] : []),
Expand Down Expand Up @@ -107,6 +114,8 @@ export function ComposerAttachmentButton(props: {
props.onPickLinearIssue?.();
} else if (nativeEvent.event === "github-issue") {
props.onPickGitHubIssue?.();
} else if (nativeEvent.event === "google") {
props.onPickGoogleFile?.();
} else if (nativeEvent.event === "notion") {
props.onPickNotionPage?.();
} else if (nativeEvent.event === "granola") {
Expand Down
Loading